feat: QR-Custom Payment + mode webhook secret GOWA
All checks were successful
CI / test (push) Successful in 1m35s
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:
163
views/admin/custom_qr.ejs
Normal file
163
views/admin/custom_qr.ejs
Normal 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>
|
||||
@@ -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 & 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;
|
||||
|
||||
Reference in New Issue
Block a user