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

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,