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

View File

@@ -2,6 +2,16 @@
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
## [0.6.0] — 2026-11-06 — "qr-custom"
### Ditambahkan
- **QR-Custom Payment** (menu baru di sidebar UTAMA): membuat QRIS nominal bebas dengan kode unik untuk kebutuhan di luar billing (sewa, donasi perusahaan, dll).
- Durasi 20 menit per QR, link share publik, QR dinamis, timer, dan halaman status.
- Webhook pembayaran mencocokkan nominal unik; pembayaran dalam 20 menit → LUNAS + notifikasi admin.
- Pembayaran dengan nominal sama **setelah 20 menit** → status `warning` + notifikasi admin, dapat diselesaikan atau ditolak/refund.
- Panel histori, statistik masuk/pending/warning/rejected, manual pay bila sinkron webhook gagal.
- **Mode Webhook Secret GOWA**: pilihan **Auto Generate** (kolom terkunci, tombol simpan nonaktif) atau **Manual** (isi secret → Simpan → terkunci, tombol Ubah untuk mengganti), plus tombol salin.
## [0.5.20] — 2026-11-06 — "copy-secret"
### Ditambahkan

View File

@@ -11,7 +11,7 @@
<p align="center">
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
<img src="https://img.shields.io/badge/versi-0.5.20_copy--secret-ADFF2F" alt="Versi 0.5.20 (copy-secret)">
<img src="https://img.shields.io/badge/versi-0.6.0_qr--custom-ADFF2F" alt="Versi 0.6.0 (qr-custom)">
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20">
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker">
<a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a>

View File

@@ -333,6 +333,17 @@ const updateWebhookPaymentNotifMatchDonation = db.prepare(`
SET matched_donation_order_id=?
WHERE id=?
`);
const selectCustomQrByUniqueAmount = db.prepare(`
SELECT id, status, title, amount_unique, expires_at
FROM custom_qr_payments
WHERE status IN ('pending','expired') AND amount_unique = ?
ORDER BY id DESC LIMIT 2
`);
const updateWebhookPaymentNotifMatchCustomQr = db.prepare(`
UPDATE webhook_payment_notifs
SET matched_custom_qr_id=?
WHERE id=?
`);
const markInvoicePaidAppendNote = db.prepare(`
UPDATE invoices
@@ -998,14 +1009,17 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
let matchedInvoiceId = null;
let matchedVoucherOrderId = null;
let matchedDonationOrderId = null;
let matchedCustomQrId = null;
if (amount != null) {
try {
const invCandidates = selectInvoiceByUniqueAmount.all(amount);
const vCandidates = selectVoucherOrderByUniqueAmount.all(amount);
const dCandidates = selectDonationOrderByUniqueAmount.all(amount);
const cqCandidates = selectCustomQrByUniqueAmount.all(amount);
const totalCandidates = (Array.isArray(invCandidates) ? invCandidates.length : 0) +
(Array.isArray(vCandidates) ? vCandidates.length : 0) +
(Array.isArray(dCandidates) ? dCandidates.length : 0);
(Array.isArray(dCandidates) ? dCandidates.length : 0) +
(Array.isArray(cqCandidates) ? cqCandidates.length : 0);
if (totalCandidates === 1) {
if (Array.isArray(invCandidates) && invCandidates.length === 1) {
@@ -1069,17 +1083,36 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
logger.error(`[WEBHOOK][payment-notif] Donation fulfill error: ${e?.message || e}`);
}
}
} else if (Array.isArray(cqCandidates) && cqCandidates.length === 1) {
const cq = cqCandidates[0];
const cqId = Number(cq.id || 0);
if (cqId > 0) {
const svc = require('./services/customQrService');
const expired = svc.isExpired(cq);
if (String(cq.status) === 'pending' && !expired) {
svc.markPaidFromWebhook(cqId, notifId || null);
logger.info(`[WEBHOOK][payment-notif] MATCH custom_qr=${cqId} amount=${amount}`);
} else {
svc.markWarningFromWebhook(cqId, notifId || null);
logger.warn(`[WEBHOOK][payment-notif] MATCH custom_qr=${cqId} amount=${amount} SETELAH 20 MENIT (warning)`);
}
matchedCustomQrId = cqId;
if (notifId) {
try { updateWebhookPaymentNotifMatchCustomQr.run(cqId, notifId); } catch {}
}
}
}
} else if (totalCandidates > 1) {
const invIds = Array.isArray(invCandidates) ? invCandidates.map(x => x.id).join(',') : '';
const vIds = Array.isArray(vCandidates) ? vCandidates.map(x => x.id).join(',') : '';
const dIds = Array.isArray(dCandidates) ? dCandidates.map(x => x.id).join(',') : '';
logger.error(`[WEBHOOK][payment-notif] MATCH ambiguous: amount=${amount} invoices=[${invIds}] vouchers=[${vIds}] donations=[${dIds}]`);
const cqIds = Array.isArray(cqCandidates) ? cqCandidates.map(x => x.id).join(',') : '';
logger.error(`[WEBHOOK][payment-notif] MATCH ambiguous: amount=${amount} invoices=[${invIds}] vouchers=[${vIds}] donations=[${dIds}] custom_qr=[${cqIds}]`);
}
// Tidak ada tagihan UNPAID dgn nominal itu → kemungkinan notifikasi ke-2 dari app lain
// (mis. GoPay & GoPay Merchant) untuk pembayaran yang SUDAH LUNAS. Hubungkan ke invoice lunas.
if (!matchedInvoiceId && !matchedVoucherOrderId && !matchedDonationOrderId) {
if (!matchedInvoiceId && !matchedVoucherOrderId && !matchedDonationOrderId && !matchedCustomQrId) {
const paidInv = selectPaidInvoiceByUniqueAmount.all(amount);
if (Array.isArray(paidInv) && paidInv.length === 1) {
matchedInvoiceId = Number(paidInv[0].id || 0) || null;
@@ -2067,6 +2100,96 @@ app.post(['/donasi/confirm', '/api/donasi/confirm'], async (req, res) => {
}
});
// ─── QR CUSTOM PAYMENT (publik) ─────────────────────────────────────────────
function cqEscape(v) {
return String(v == null ? '' : v).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
app.get('/custom-qr/:token', (req, res) => {
let row = null;
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
if (!row) return res.status(404).send('QR tidak ditemukan atau sudah dihapus.');
const settings = getSettingsWithCache();
const company = settings.company_header || 'OPEN-ISP';
const statusLabels = { pending: 'Menunggu Pembayaran', paid: 'LUNAS', expired: 'Kedaluwarsa', warning: 'Perlu Verifikasi Admin', rejected: 'Ditolak / Refund' };
const status = String(row.status || 'pending');
const showQr = status !== 'paid' && status !== 'rejected';
const amountText = 'Rp ' + Number(row.amount_unique || 0).toLocaleString('id-ID');
const baseAmountText = 'Rp ' + Number(row.amount || 0).toLocaleString('id-ID');
const html = `<!doctype html><html lang="id"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${cqEscape(row.title)} — ${cqEscape(company)}</title>
<style>
body{margin:0;background:#0b1220;color:#e5e7eb;font-family:system-ui,Segoe UI,Arial;display:grid;place-items:center;min-height:100vh;padding:20px}
.card{max-width:460px;width:100%;background:#0f172a;border:1px solid rgba(148,163,184,.2);border-radius:16px;padding:22px;text-align:center}
h1{font-size:19px;margin:0 0 4px}p{margin:6px 0;color:#94a3b8;font-size:13px}
.amt{font-size:26px;font-weight:800;color:#ADFF2F;margin:12px 0 2px}.base{font-size:12px;color:#64748b}
.qr{background:#fff;padding:12px;border-radius:14px;display:inline-block;margin:14px 0}.qr img{display:block;width:240px;height:240px}
.badge{display:inline-block;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:700;margin-top:6px}
.pending{background:rgba(245,158,11,.15);color:#fbbf24}.paid{background:rgba(34,197,94,.15);color:#4ade80}
.expired{background:rgba(239,68,68,.15);color:#f87171}.warning{background:rgba(249,115,22,.15);color:#fb923c}.rejected{background:rgba(148,163,184,.15);color:#cbd5e1}
.timer{font-size:14px;color:#fbbf24;margin-top:8px}.note{font-size:12px;color:#64748b;margin-top:12px}
</style></head><body><div class="card">
<div style="font-size:12px;letter-spacing:1px;color:#64748b;text-transform:uppercase">${cqEscape(row.category || 'Pembayaran')}</div>
<h1>${cqEscape(row.title)}</h1>
<div class="amt">${amountText}</div>
<div class="base">Nominal dasar ${baseAmountText} + kode unik ${Number(row.unique_code || 0)}</div>
<div class="badge ${cqEscape(status)}" id="statusBadge">${cqEscape(statusLabels[status] || status)}</div>
${showQr ? `<div class="qr"><img src="/custom-qr/${cqEscape(row.share_token)}/qr.png?amount=${Number(row.amount_unique || 0)}" alt="QRIS"></div>` : ''}
<div class="timer" id="timer" data-expires="${cqEscape(row.expires_at || '')}"></div>
${row.note ? `<div class="note">${cqEscape(row.note)}</div>` : ''}
<div class="note">Bayar dengan nominal <b>persis</b> seperti di atas agar pembayaran otomatis tercocokkan. QR berlaku ${require('./services/customQrService').DURATION_MINUTES} menit.</div>
<div class="note">${cqEscape(company)}</div>
</div>
<script>
(function(){
var el=document.getElementById('timer'), exp=el&&el.getAttribute('data-expires');
function tick(){
if(!el||!exp) return;
var d=new Date(exp).getTime()-Date.now();
if(d<=0){ el.textContent='Batas 20 menit telah lewat — pembayaran tetap dicek admin.'; 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);
var last=${JSON.stringify(status)};
setInterval(function(){
fetch('/custom-qr/${cqEscape(row.share_token)}/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
if(d && d.status && d.status!==last){ location.reload(); }
}).catch(function(){});
},5000);
})();
</script></body></html>`;
res.set('Cache-Control', 'no-store');
return res.send(html);
});
app.get('/custom-qr/:token/qr.png', async (req, res) => {
let row = null;
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
if (!row) return res.status(404).send('QR tidak ditemukan');
const settings = getSettingsWithCache();
const payload = String(settings.qris_static_payload || '').trim();
if (!payload) return res.status(404).send('QRIS statis belum diatur admin.');
try {
const jpg = await qrisUtil.buildDynamicQrisJpgBuffer(payload, Number(row.amount_unique || row.amount || 0));
res.set('Content-Type', 'image/jpeg');
res.set('Cache-Control', 'no-store');
return res.send(jpg);
} catch (e) {
logger.error('[CustomQR] Gagal membuat QR: ' + e.message);
return res.status(500).send('Gagal membuat QR');
}
});
app.get('/custom-qr/:token/status', (req, res) => {
let row = null;
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
if (!row) return res.status(404).json({ status: 'not_found' });
return res.json({ status: row.status, paid_at: row.paid_at || null });
});
// Mount built-in ACS server endpoint (TR-069)
const acsServerService = require('./services/acsServerService');
app.post('/acs', express.raw({ type: ['text/xml', 'application/soap+xml', 'application/xml', 'text/plain'], limit: '2mb' }), acsServerService.handleCwmpRequest);

View File

@@ -1069,6 +1069,42 @@ try {
console.error('Failed to migrate public_donation_orders:', e);
}
// Safe migration: QR Custom Payment (nominal bebas di luar billing)
try {
db.exec(`
CREATE TABLE IF NOT EXISTS custom_qr_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT DEFAULT '',
amount INTEGER NOT NULL DEFAULT 0,
unique_code INTEGER,
amount_unique INTEGER,
status TEXT DEFAULT 'pending',
share_token TEXT NOT NULL,
note TEXT DEFAULT '',
created_by TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL()),
expires_at TEXT,
paid_at DATETIME,
paid_by TEXT DEFAULT '',
notif_id INTEGER,
warning_at DATETIME,
resolved_by TEXT DEFAULT '',
resolved_at DATETIME,
resolution_note TEXT DEFAULT ''
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_qr_token ON custom_qr_payments(share_token);
CREATE INDEX IF NOT EXISTS idx_custom_qr_status ON custom_qr_payments(status);
CREATE INDEX IF NOT EXISTS idx_custom_qr_amount ON custom_qr_payments(amount_unique, status);
`);
const cols = db.prepare("PRAGMA table_info(webhook_payment_notifs)").all();
if (cols.length > 0 && !cols.some(c => c.name === 'matched_custom_qr_id')) {
db.exec("ALTER TABLE webhook_payment_notifs ADD COLUMN matched_custom_qr_id INTEGER");
}
} catch(e) {
console.error('Failed to migrate custom_qr_payments:', e);
}
// Safe migration: automation_runs (riwayat otomatisasi pengiriman tagihan)
try {
db.exec(`

View File

@@ -1,6 +1,6 @@
{
"name": "open-isp",
"version": "0.5.20",
"version": "0.6.0",
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
"main": "app-customer.js",
"scripts": {

View File

@@ -4013,6 +4013,70 @@ router.post('/sidebar-settings', requireAdminSession, restrictToAdmin, express.u
res.redirect('/admin/sidebar-settings');
});
// ─── QR CUSTOM PAYMENT (nominal bebas, kode unik 20 menit) ─────────────────
router.get('/custom-qr', requireAdminSession, requireSidebarMenuAccess('custom_qr'), (req, res) => {
const svc = require('../services/customQrService');
let detail = null;
const detailId = Number(req.query.id) || 0;
if (detailId > 0) { try { detail = svc.getById(detailId); } catch (e) {} }
const settings = getSettings();
const baseUrl = String(settings.public_base_url || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '');
res.render('admin/custom_qr', {
title: 'QR-Custom Payment',
company: company(),
activePage: 'custom_qr',
msg: flashMsg(req),
rows: svc.list(150),
stats: svc.stats(),
detail,
baseUrl,
durationMinutes: svc.DURATION_MINUTES,
hasQrisPayload: !!String(settings.qris_static_payload || '').trim()
});
});
router.post('/custom-qr', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
const svc = require('../services/customQrService');
const row = svc.create({
title: req.body.title,
category: req.body.category,
amount: req.body.amount,
note: req.body.note,
createdBy: resolvePaidByName(req, 'Admin')
});
req.session._msg = { type: 'success', text: `QR custom dibuat: Rp ${Number(row.amount_unique).toLocaleString('id-ID')} — berlaku ${svc.DURATION_MINUTES} menit.` };
return res.redirect('/admin/custom-qr?id=' + row.id);
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal membuat QR custom: ' + e.message };
return res.redirect('/admin/custom-qr');
}
});
router.post('/custom-qr/:id/manual-paid', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
require('../services/customQrService').markManualPaid(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
req.session._msg = { type: 'success', text: 'Pembayaran custom ditandai LUNAS (manual).' };
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
});
router.post('/custom-qr/:id/accept', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
require('../services/customQrService').acceptWarning(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
req.session._msg = { type: 'success', text: 'Pembayaran setelah 20 menit diterima.' };
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
});
router.post('/custom-qr/:id/reject', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
require('../services/customQrService').reject(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
req.session._msg = { type: 'success', text: 'Pembayaran ditolak / refund dicatat.' };
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
});
router.get('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), (req, res) => {
let routers = [];
try { routers = db.prepare('SELECT id, name FROM routers WHERE is_active = 1 ORDER BY name ASC').all(); } catch (_) {}
@@ -6814,6 +6878,7 @@ router.get('/whatsapp', requireAdminSession, requireSidebarMenuAccess('whatsapp'
try { gowaWebhookSecret = require('../config/settingsManager').ensureGowaWebhookSecret(); } catch (e) {}
const gowaSettings = {
gowa_mode: getSetting('gowa_mode', /^https:/i.test(String(gowaBaseUrl)) ? 'cloud' : 'local'),
gowa_secret_mode: getSetting('gowa_secret_mode', 'auto') === 'manual' ? 'manual' : 'auto',
gowa_base_url: gowaBaseUrl,
gowa_username: getSetting('gowa_username', ''),
gowa_password: getSetting('gowa_password', ''),
@@ -6908,6 +6973,31 @@ router.post('/whatsapp/test-gowa-connection', requireAdminSession, express.json(
}
});
router.post('/whatsapp/gowa-secret', requireAdminSession, express.json(), async (req, res) => {
try {
const sm = require('../config/settingsManager');
const b = req.body || {};
const mode = String(b.mode || '').toLowerCase() === 'manual' ? 'manual' : 'auto';
if (mode === 'auto') {
const secret = sm.ensureGowaWebhookSecret(true);
saveSettings({ gowa_secret_mode: 'auto' });
return res.json({ ok: true, mode: 'auto', secret });
}
const secret = String(b.secret || '').trim();
if (secret) {
if (secret === 'secret' || secret.length < 16) {
return res.status(400).json({ ok: false, message: 'Secret minimal 16 karakter dan tidak boleh "secret".' });
}
saveSettings({ gowa_webhook_secret: secret, gowa_secret_mode: 'manual' });
return res.json({ ok: true, mode: 'manual', saved: true, secret });
}
saveSettings({ gowa_secret_mode: 'manual' });
return res.json({ ok: true, mode: 'manual', saved: false, secret: getSetting('gowa_webhook_secret', '') });
} catch (e) {
return res.status(500).json({ ok: false, message: e.message });
}
});
router.post('/whatsapp/test-gowa', requireAdminSession, express.json(), async (req, res) => {
try {
const { phone, message } = req.body;

177
services/customQrService.js Normal file
View File

@@ -0,0 +1,177 @@
'use strict';
/**
* customQrService.js — QR nominal bebas (di luar billing) dengan kode unik 20 menit.
* Status: pending -> paid | expired -> warning | rejected
*/
const crypto = require('crypto');
const db = require('../config/database');
const { isQrisAmountTaken } = require('./invoiceQrisService');
const { logger } = require('../config/logger');
const DURATION_MINUTES = 20;
function nowIso() { return new Date().toISOString(); }
function sweepExpired() {
try {
db.prepare("UPDATE custom_qr_payments SET status='expired' WHERE status='pending' AND expires_at IS NOT NULL AND expires_at < ?").run(nowIso());
} catch (e) {}
}
function isTaken(amount, excludeId) {
if (isQrisAmountTaken(amount, { excludeCustomQrId: excludeId || 0 })) return true;
try {
const row = db.prepare("SELECT id FROM custom_qr_payments WHERE status IN ('pending','expired') AND amount_unique=? AND id!=? LIMIT 1").get(amount, Number(excludeId) || 0);
return !!(row && row.id);
} catch (e) { return false; }
}
function create({ title, category, amount, note, createdBy }) {
const base = Math.max(1, Math.floor(Number(amount) || 0));
if (!title || !String(title).trim()) throw new Error('Judul wajib diisi.');
if (!base) throw new Error('Nominal wajib diisi.');
let code = 0;
let amountUnique = 0;
for (let i = 0; i < 60; i++) {
const c = 1 + Math.floor(Math.random() * 999);
if (!isTaken(base + c)) { code = c; amountUnique = base + c; break; }
}
if (!amountUnique) {
for (let c = 1; c <= 999; c++) {
if (!isTaken(base + c)) { code = c; amountUnique = base + c; break; }
}
}
if (!amountUnique) throw new Error('Gagal membuat kode unik (slot 1-999 penuh).');
const token = crypto.randomBytes(12).toString('hex');
const expiresAt = new Date(Date.now() + DURATION_MINUTES * 60 * 1000).toISOString();
const r = db.prepare(`
INSERT INTO custom_qr_payments (title, category, amount, unique_code, amount_unique, status, share_token, note, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)
`).run(String(title).trim(), String(category || '').trim(), base, code, amountUnique, token, String(note || '').trim(), String(createdBy || ''), expiresAt);
return getById(Number(r.lastInsertRowid));
}
function getById(id) {
sweepExpired();
return db.prepare('SELECT * FROM custom_qr_payments WHERE id=?').get(Number(id) || 0);
}
function getByToken(token) {
sweepExpired();
return db.prepare('SELECT * FROM custom_qr_payments WHERE share_token=?').get(String(token || ''));
}
function list(limit = 100) {
sweepExpired();
return db.prepare('SELECT * FROM custom_qr_payments ORDER BY id DESC LIMIT ?').all(Math.max(1, Math.min(500, Number(limit) || 100)));
}
function stats() {
sweepExpired();
const paid = db.prepare("SELECT COUNT(*) c, COALESCE(SUM(amount_unique),0) t FROM custom_qr_payments WHERE status='paid'").get();
const pending = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='pending'").get();
const warning = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='warning'").get();
const rejected = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='rejected'").get();
return {
paidCount: Number(paid.c || 0),
paidTotal: Number(paid.t || 0),
pendingCount: Number(pending.c || 0),
warningCount: Number(warning.c || 0),
rejectedCount: Number(rejected.c || 0)
};
}
function candidatesByAmount(amount) {
sweepExpired();
return db.prepare("SELECT * FROM custom_qr_payments WHERE amount_unique=? AND status IN ('pending','expired') ORDER BY id DESC LIMIT 2").all(Number(amount) || 0);
}
function markManualPaid(id, actor, note) {
const row = getById(id);
if (!row) throw new Error('Data tidak ditemukan.');
if (row.status === 'paid') return row;
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by=?, resolution_note=? WHERE id=?")
.run(String(actor || 'Admin'), String(note || 'Manual pay'), row.id);
return getById(id);
}
function acceptWarning(id, actor, note) {
const row = getById(id);
if (!row) throw new Error('Data tidak ditemukan.');
if (row.status !== 'warning') throw new Error('Hanya pembayaran berstatus warning yang bisa diselesaikan.');
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by=?, resolved_by=?, resolved_at=NOW_LOCAL(), resolution_note=? WHERE id=?")
.run(String(actor || 'Admin'), String(actor || 'Admin'), String(note || 'Diterima setelah 20 menit'), row.id);
return getById(id);
}
function reject(id, actor, note) {
const row = getById(id);
if (!row) throw new Error('Data tidak ditemukan.');
if (row.status === 'rejected') return row;
db.prepare("UPDATE custom_qr_payments SET status='rejected', resolved_by=?, resolved_at=NOW_LOCAL(), resolution_note=? WHERE id=?")
.run(String(actor || 'Admin'), String(note || 'Refund / ditolak'), row.id);
return getById(id);
}
async function notifyAdminPayment(row, isWarning) {
try {
const waNotify = require('./waNotifyService');
const text = isWarning
? `⚠️ *PEMBAYARAN QR CUSTOM (LEWAT 20 MENIT)*\n\n` +
`Judul: ${row.title}\nKategori: ${row.category || '-'}\n` +
`Nominal: Rp ${Number(row.amount_unique || 0).toLocaleString('id-ID')}\n` +
`Dibuat: ${row.created_at}\n\n` +
`Nominal persis masuk setelah batas waktu. Buka menu QR-Custom Payment untuk menyelesaikan atau menolak (refund).`
: `✅ *PEMBAYARAN QR CUSTOM DITERIMA*\n\n` +
`Judul: ${row.title}\nKategori: ${row.category || '-'}\n` +
`Nominal: Rp ${Number(row.amount_unique || 0).toLocaleString('id-ID')}\n\n` +
`Pembayaran cocok dalam 20 menit.`;
await waNotify.notifyAdmin(text);
} catch (e) { logger.warn('[CustomQR] notifyAdmin gagal: ' + e.message); }
}
function markPaidFromWebhook(id, notifId) {
const row = getById(id);
if (!row) return null;
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by='Webhook', notif_id=? WHERE id=?")
.run(notifId || null, row.id);
const fresh = getById(id);
notifyAdminPayment(fresh, false).catch(() => {});
return fresh;
}
function markWarningFromWebhook(id, notifId) {
const row = getById(id);
if (!row) return null;
db.prepare("UPDATE custom_qr_payments SET status='warning', warning_at=NOW_LOCAL(), notif_id=? WHERE id=?")
.run(notifId || null, row.id);
const fresh = getById(id);
notifyAdminPayment(fresh, true).catch(() => {});
return fresh;
}
function isExpired(row) {
if (!row || !row.expires_at) return false;
const t = new Date(row.expires_at).getTime();
return Number.isFinite(t) && t < Date.now();
}
module.exports = {
DURATION_MINUTES,
create,
list,
stats,
getById,
getByToken,
candidatesByAmount,
markManualPaid,
acceptWarning,
reject,
markPaidFromWebhook,
markWarningFromWebhook,
isExpired,
sweepExpired
};

View File

@@ -19,6 +19,7 @@ function isQrisAmountTaken(amount, opts = {}) {
const exInv = Number(opts.excludeInvoiceId || 0);
const exVoucher = Number(opts.excludeVoucherOrderId || 0);
const exDonation = Number(opts.excludeDonationOrderId || 0);
const exCustomQr = Number(opts.excludeCustomQrId || 0);
try {
const inv = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('unpaid', amt, exInv);
if (inv && inv.id) return true;
@@ -26,6 +27,8 @@ function isQrisAmountTaken(amount, opts = {}) {
if (voc && voc.id) return true;
const don = db.prepare('SELECT id FROM public_donation_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, exDonation);
if (don && don.id) return true;
const cq = db.prepare("SELECT id FROM custom_qr_payments WHERE status IN ('pending','expired') AND amount_unique=? AND id!=? LIMIT 1").get(amt, exCustomQr);
if (cq && cq.id) return true;
} catch (e) { /* tabel opsional belum ada */ }
return false;
}

View File

@@ -32,6 +32,7 @@ const MENU_DEFINITIONS = [
{ key: 'map', section: 'main', href: '/admin/map', icon: 'bi bi-map', labelKey: 'admin.nav.network_map', labelDefault: 'Peta Jaringan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 4, activePages: ['map'] },
{ key: 'whatsapp', section: 'main', href: '/admin/whatsapp', icon: 'bi bi-whatsapp', labelKey: 'admin.nav.whatsapp', labelDefault: 'WhatsApp', roles: ['admin', 'cashier'], activePages: ['whatsapp','broadcast','whatsapp_live_chat','whatsapp_templates'] },
{ key: 'automation', section: 'main', href: '/admin/automation', icon: 'bi bi-robot', labelKey: 'admin.nav.automation', labelDefault: 'Automation', roles: ['admin'], activePages: ['automation'] },
{ key: 'custom_qr', section: 'main', href: '/admin/custom-qr', icon: 'bi bi-qr-code-simple', labelKey: 'admin.nav.custom_qr', labelDefault: 'QR-Custom Payment', roles: ['admin'], activePages: ['custom_qr'] },
{ key: 'grp_customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers', labelDefault: 'Pelanggan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 2, activePages: ['customers', 'onu_stickers'], children: [
{ key: 'customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers_data', labelDefault: 'Data Pelanggan', roles: ['admin', 'cashier'], activePages: ['customers'] },
@@ -103,6 +104,7 @@ const DEFAULT_MENU_STATES = {
olts: STATE_HIDDEN,
whatsapp: STATE_VISIBLE,
automation: STATE_VISIBLE,
custom_qr: STATE_VISIBLE,
broadcast: STATE_VISIBLE,
whatsapp_live_chat: STATE_VISIBLE,
promo_slides: STATE_VISIBLE,

View File

@@ -1 +1 @@
0.5.20
0.6.0

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;