feat: QR-Custom Payment + mode webhook secret GOWA
All checks were successful
CI / test (push) Successful in 1m35s

- tabel custom_qr_payments, service, halaman admin + publik share
- kode unik 20 menit, warning bila bayar setelah batas, manual pay/accept/reject
- webhook payment-notif mencocokkan nominal custom
- menu sidebar QR-Custom Payment
- GOWA secret: auto generate (locked) / manual (simpan lalu locked, ubah)
- versi 0.6.0
This commit is contained in:
oxpoe
2026-11-06 04:32:06 +07:00
parent 973d9e3a14
commit bf25260d4c
12 changed files with 689 additions and 8 deletions

163
views/admin/custom_qr.ejs Normal file
View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: activePage || 'custom_qr', company }) %>
<div class="mw">
<div class="topbar">
<div class="tb-title"><button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button><i class="bi bi-qr-code-simple"></i> QR-Custom Payment</div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> Admin</span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i> Logout</a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<% if (typeof hasQrisPayload !== 'undefined' && !hasQrisPayload) { %>
<div class="alert alert-d"><i class="bi bi-exclamation-triangle"></i> QRIS statis belum diatur. Isi <b>QRIS static payload</b> di Pengaturan agar QR bisa dibuat.</div>
<% } %>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-plus-circle"></i> Buat QR Nominal Custom</h6></div>
<div class="card-bd">
<form method="POST" action="/admin/custom-qr">
<div class="form-row">
<div class="fg" style="flex:2"><label class="fl">Judul *</label><input class="fc" name="title" required placeholder="mis. Pembayaran sewa tower / donasi perusahaan"></div>
<div class="fg"><label class="fl">Kategori</label><input class="fc" name="category" placeholder="mis. Sewa, Donasi, Lain-lain"></div>
<div class="fg"><label class="fl">Nominal (Rp) *</label><input class="fc" type="number" min="1" name="amount" required placeholder="100000"></div>
</div>
<div class="form-row">
<div class="fg" style="flex:2"><label class="fl">Catatan (opsional)</label><input class="fc" name="note" placeholder="Keterangan untuk penerima link"></div>
<div class="fg" style="display:flex;align-items:flex-end"><button class="btn btn-p" type="submit"><i class="bi bi-qr-code"></i> Generate QR (20 menit)</button></div>
</div>
</form>
</div>
</div>
<div class="form-row mb16">
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Pembayaran masuk</div><div style="font-size:22px;font-weight:800"><%= stats.paidCount %></div><div class="muted" style="font-size:11px">Rp <%= Number(stats.paidTotal||0).toLocaleString('id-ID') %></div></div></div></div>
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Menunggu (pending)</div><div style="font-size:22px;font-weight:800;color:#fbbf24"><%= stats.pendingCount %></div></div></div></div>
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Warning (lewat 20 menit)</div><div style="font-size:22px;font-weight:800;color:#fb923c"><%= stats.warningCount %></div></div></div></div>
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Ditolak / refund</div><div style="font-size:22px;font-weight:800"><%= stats.rejectedCount %></div></div></div></div>
</div>
<% if (detail) { %>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-qr-code"></i> <%= detail.title %> — Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></h6></div>
<div class="card-bd" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:18px">
<div style="text-align:center">
<% if (detail.status === 'paid' || detail.status === 'rejected') { %>
<div style="padding:24px;border:1px dashed var(--border);border-radius:12px;color:var(--muted)">QR tidak aktif — status: <b><%= detail.status %></b></div>
<% } else { %>
<img src="/custom-qr/<%= detail.share_token %>/qr.png" alt="QRIS" style="max-width:240px;background:#fff;padding:10px;border-radius:12px">
<div id="cqTimer" data-expires="<%= detail.expires_at || '' %>" style="margin-top:8px;font-size:13px;color:#fbbf24"></div>
<% } %>
</div>
<div style="font-size:13px;line-height:1.8">
<div><b>Status:</b> <%= detail.status %></div>
<div><b>Nominal dasar:</b> Rp <%= Number(detail.amount||0).toLocaleString('id-ID') %></div>
<div><b>Kode unik:</b> <%= detail.unique_code %></div>
<div><b>Nominal bayar:</b> Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></div>
<div><b>Kategori:</b> <%= detail.category || '-' %></div>
<div><b>Dibuat:</b> <%= detail.created_at %></div>
<div><b>Kedaluwarsa:</b> <%= detail.expires_at || '-' %></div>
<% if (detail.note) { %><div><b>Catatan:</b> <%= detail.note %></div><% } %>
<div style="margin-top:8px"><b>Link share:</b><br>
<input class="fc" readonly id="cqLink" value="<%= baseUrl %>/custom-qr/<%= detail.share_token %>" style="font-size:12px">
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
<button class="btn btn-g btn-sm" onclick="copyCqLink()"><i class="bi bi-copy"></i> Salin Link</button>
<a class="btn btn-g btn-sm" href="<%= baseUrl %>/custom-qr/<%= detail.share_token %>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i> Buka</a>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">
<% if (detail.status !== 'paid') { %>
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/manual-paid" onsubmit="return confirm('Tandai pembayaran ini LUNAS secara manual?')">
<input type="hidden" name="note" value="Manual pay dari panel admin">
<button class="btn btn-p btn-sm"><i class="bi bi-check2-circle"></i> Manual Pay</button>
</form>
<% } %>
<% if (detail.status === 'warning') { %>
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/accept" onsubmit="return confirm('Terima pembayaran setelah 20 menit?')">
<button class="btn btn-p btn-sm"><i class="bi bi-check2"></i> Selesaikan</button>
</form>
<% } %>
<% if (detail.status !== 'paid' && detail.status !== 'rejected') { %>
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/reject" onsubmit="return confirm('Tolak / refund pembayaran ini?')">
<input type="hidden" name="note" value="Refund dari panel admin">
<button class="btn btn-g btn-sm"><i class="bi bi-x-circle"></i> Reject / Refund</button>
</form>
<% } %>
</div>
</div>
</div>
</div>
<% } %>
<div class="card">
<div class="card-hd"><h6><i class="bi bi-clock-history"></i> Histori QR Custom</h6></div>
<div class="card-bd" style="overflow:auto">
<table class="table" style="width:100%;font-size:12.5px">
<thead><tr><th>Judul</th><th>Kategori</th><th>Nominal</th><th>Status</th><th>Dibuat</th><th>Kedaluwarsa</th><th>Aksi</th></tr></thead>
<tbody>
<% if (!rows || rows.length === 0) { %>
<tr><td colspan="7" class="muted" style="text-align:center;padding:20px">Belum ada QR custom.</td></tr>
<% } %>
<% rows.forEach(function(r) { %>
<tr>
<td><b><%= r.title %></b><% if (r.note) { %><div class="muted" style="font-size:11px"><%= r.note %></div><% } %></td>
<td><%= r.category || '-' %></td>
<td>Rp <%= Number(r.amount_unique||0).toLocaleString('id-ID') %></td>
<td>
<span style="font-weight:700;color:<%= r.status==='paid'?'#4ade80':(r.status==='warning'?'#fb923c':(r.status==='rejected'?'#94a3b8':(r.status==='expired'?'#f87171':'#fbbf24'))) %>"><%= r.status %></span>
</td>
<td><%= r.created_at %></td>
<td><%= r.expires_at || '-' %></td>
<td style="white-space:nowrap">
<a class="btn btn-g btn-sm" href="/admin/custom-qr?id=<%= r.id %>"><i class="bi bi-eye"></i></a>
<% if (r.status !== 'paid' && r.status !== 'rejected') { %>
<form method="POST" action="/admin/custom-qr/<%= r.id %>/manual-paid" style="display:inline" onsubmit="return confirm('Tandai LUNAS manual?')">
<button class="btn btn-g btn-sm" title="Manual pay"><i class="bi bi-check2-circle"></i></button>
</form>
<% } %>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function copyCqLink() {
var el = document.getElementById('cqLink');
if (!el) return;
try { el.select(); document.execCommand('copy'); } catch (e) {}
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(el.value).catch(function () {});
}
(function () {
var el = document.getElementById('cqTimer');
if (!el) return;
var exp = el.getAttribute('data-expires');
function tick() {
if (!exp) { el.textContent = ''; return; }
var d = new Date(exp).getTime() - Date.now();
if (d <= 0) { el.textContent = 'Lewat 20 menit — pembayaran tetap dicek.'; return; }
var m = Math.floor(d / 60000), s = Math.floor((d % 60000) / 1000);
el.textContent = 'Sisa waktu: ' + m + ' menit ' + s + ' detik';
}
tick(); setInterval(tick, 1000);
})();
</script>
</body>
</html>

View File

@@ -219,12 +219,22 @@
</div>
</div>
<div class="fg" style="flex:1;">
<label class="fl">Mode Webhook Secret</label>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button type="button" class="btn btn-sm <%= gowaSettings.gowa_secret_mode === 'auto' ? 'btn-p' : 'btn-g' %>" id="gsm-btn-auto" onclick="gowaSecretMode('auto')"><i class="bi bi-magic"></i> Auto Generate</button>
<button type="button" class="btn btn-sm <%= gowaSettings.gowa_secret_mode === 'manual' ? 'btn-p' : 'btn-g' %>" id="gsm-btn-manual" onclick="gowaSecretMode('manual')"><i class="bi bi-pencil-square"></i> Manual</button>
</div>
<label class="fl">Webhook Secret (HMAC)</label>
<div style="display:flex;gap:8px;">
<input class="fc" id="gowa_webhook_secret" name="gowa_webhook_secret" value="<%= gowaSettings.gowa_webhook_secret %>" placeholder="otomatis dibuat">
<input class="fc" id="gowa_webhook_secret" name="gowa_webhook_secret" data-mode="<%= gowaSettings.gowa_secret_mode %>" data-locked="1" value="<%= gowaSettings.gowa_webhook_secret %>" placeholder="otomatis dibuat">
<button type="button" class="btn btn-g" onclick="copyGowaSecret(this)" title="Salin webhook secret"><i class="bi bi-copy"></i></button>
</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Dibuat otomatis (32 byte). Samakan dengan <code>WHATSAPP_WEBHOOK_SECRET</code> di GOWA.</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
<button type="button" class="btn btn-p btn-sm" id="gsm-btn-save" onclick="gowaSecretSave(this)"><i class="bi bi-save"></i> Simpan Secret</button>
<button type="button" class="btn btn-g btn-sm" id="gsm-btn-edit" onclick="gowaSecretEdit()"><i class="bi bi-unlock"></i> Ubah</button>
</div>
<div id="gowa_secret_status" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Auto Generate: secret dibuat &amp; dikunci otomatis. Manual: isi sendiri lalu klik Simpan Secret. Samakan dengan <code>WHATSAPP_WEBHOOK_SECRET</code> di GOWA.</div>
</div>
</div>
</div>
@@ -350,6 +360,73 @@
render();
_gowaQrTick = setInterval(function () { remaining--; if (remaining < 0) remaining = 0; render(); }, 1000);
}
function gowaSecretRender() {
var input = document.getElementById('gowa_webhook_secret');
if (!input) return;
var mode = input.getAttribute('data-mode') || 'auto';
var locked = (mode === 'auto') ? true : (input.getAttribute('data-locked') === '1');
var save = document.getElementById('gsm-btn-save');
var edit = document.getElementById('gsm-btn-edit');
var auto = document.getElementById('gsm-btn-auto');
var manual = document.getElementById('gsm-btn-manual');
var st = document.getElementById('gowa_secret_status');
input.readOnly = locked;
if (save) save.disabled = (mode === 'auto') || locked;
if (edit) edit.style.display = (mode === 'manual' && locked) ? 'inline-block' : 'none';
if (auto) auto.className = 'btn btn-sm ' + (mode === 'auto' ? 'btn-p' : 'btn-g');
if (manual) manual.className = 'btn btn-sm ' + (mode === 'manual' ? 'btn-p' : 'btn-g');
if (st) {
if (mode === 'auto') st.textContent = 'Mode otomatis: secret dibuat dan dikunci. Salin ke GOWA.';
else if (locked) st.textContent = 'Mode manual: secret tersimpan dan dikunci. Klik Ubah untuk mengganti.';
else st.textContent = 'Mode manual: masukkan secret (min. 16 karakter), lalu Simpan Secret.';
}
}
async function gowaSecretMode(mode) {
var input = document.getElementById('gowa_webhook_secret');
if (!input) return;
try {
var r = await fetch('/admin/whatsapp/gowa-secret', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: mode })
});
var d = await r.json();
if (!d.ok) { alert(d.message || 'Gagal mengubah mode secret.'); return; }
if (d.secret) input.value = d.secret;
input.setAttribute('data-mode', d.mode);
input.setAttribute('data-locked', d.mode === 'manual' ? '0' : '1');
gowaSecretRender();
} catch (e) { alert('Gagal: ' + e.message); }
}
async function gowaSecretSave(btn) {
var input = document.getElementById('gowa_webhook_secret');
if (!input) return;
var secret = String(input.value || '').trim();
if (secret.length < 16) { alert('Secret minimal 16 karakter.'); return; }
var old = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menyimpan...'; }
try {
var r = await fetch('/admin/whatsapp/gowa-secret', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'manual', secret: secret })
});
var d = await r.json();
if (!d.ok) { alert(d.message || 'Gagal menyimpan secret.'); return; }
input.value = d.secret || secret;
input.setAttribute('data-mode', 'manual');
input.setAttribute('data-locked', '1');
gowaSecretRender();
} catch (e) { alert('Gagal: ' + e.message); }
finally { if (btn) { btn.disabled = false; btn.innerHTML = old; } gowaSecretRender(); }
}
function gowaSecretEdit() {
var input = document.getElementById('gowa_webhook_secret');
if (!input || input.getAttribute('data-mode') !== 'manual') return;
input.setAttribute('data-locked', '0');
gowaSecretRender();
input.focus();
}
document.addEventListener('DOMContentLoaded', gowaSecretRender);
function copyGowaSecret(btn) {
var el = document.getElementById('gowa_webhook_secret');
if (!el) return;