Nominal dasar ${baseAmountText} + kode unik ${Number(row.unique_code || 0)}
+
${cqEscape(statusLabels[status] || status)}
+${showQr ? `
` : ''}
+
+${row.note ? `
${cqEscape(row.note)}
` : ''}
+
Bayar dengan nominal persis seperti di atas agar pembayaran otomatis tercocokkan. QR berlaku ${require('./services/customQrService').DURATION_MINUTES} menit.
+
${cqEscape(company)}
+
+`;
+ 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);
diff --git a/config/database.js b/config/database.js
index 53dd992..5531b26 100644
--- a/config/database.js
+++ b/config/database.js
@@ -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(`
diff --git a/package.json b/package.json
index b2bda33..c7548c4 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/routes/adminPortal.js b/routes/adminPortal.js
index 4386303..d4cb9f4 100644
--- a/routes/adminPortal.js
+++ b/routes/adminPortal.js
@@ -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;
diff --git a/services/customQrService.js b/services/customQrService.js
new file mode 100644
index 0000000..6856750
--- /dev/null
+++ b/services/customQrService.js
@@ -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
+};
diff --git a/services/invoiceQrisService.js b/services/invoiceQrisService.js
index b135a77..09eae1d 100644
--- a/services/invoiceQrisService.js
+++ b/services/invoiceQrisService.js
@@ -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;
}
diff --git a/services/sidebarMenuService.js b/services/sidebarMenuService.js
index 92a05df..eaed894 100644
--- a/services/sidebarMenuService.js
+++ b/services/sidebarMenuService.js
@@ -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,
diff --git a/version.txt b/version.txt
index 3c7d01c..09a3acf 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-0.5.20
\ No newline at end of file
+0.6.0
\ No newline at end of file
diff --git a/views/admin/custom_qr.ejs b/views/admin/custom_qr.ejs
new file mode 100644
index 0000000..abfdaab
--- /dev/null
+++ b/views/admin/custom_qr.ejs
@@ -0,0 +1,163 @@
+
+
+
+
+ <%= title %> | <%= company %>
+
+
+
+
+<%- include('partials/sidebar', { activePage: activePage || 'custom_qr', company }) %>
+