From bf25260d4c06c92960dba72eba274cee4a99a9ea Mon Sep 17 00:00:00 2001 From: oxpoe Date: Fri, 6 Nov 2026 04:32:06 +0700 Subject: [PATCH] feat: QR-Custom Payment + mode webhook secret GOWA - 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 --- CHANGELOG.md | 10 ++ README.md | 2 +- app-customer.js | 129 +++++++++++++++++++++++- config/database.js | 36 +++++++ package.json | 2 +- routes/adminPortal.js | 90 +++++++++++++++++ services/customQrService.js | 177 +++++++++++++++++++++++++++++++++ services/invoiceQrisService.js | 3 + services/sidebarMenuService.js | 2 + version.txt | 2 +- views/admin/custom_qr.ejs | 163 ++++++++++++++++++++++++++++++ views/admin/whatsapp.ejs | 81 ++++++++++++++- 12 files changed, 689 insertions(+), 8 deletions(-) create mode 100644 services/customQrService.js create mode 100644 views/admin/custom_qr.ejs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f6bcdc..2b00ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5144fb8..42618aa 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

Lisensi: ISC - Versi 0.5.20 (copy-secret) + Versi 0.6.0 (qr-custom) Node.js >= 20 Docker Demo diff --git a/app-customer.js b/app-customer.js index 11c8017..b3e999b 100644 --- a/app-customer.js +++ b/app-customer.js @@ -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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 = ` +${cqEscape(row.title)} — ${cqEscape(company)} +

+
${cqEscape(row.category || 'Pembayaran')}
+

${cqEscape(row.title)}

+
${amountText}
+
Nominal dasar ${baseAmountText} + kode unik ${Number(row.unique_code || 0)}
+
${cqEscape(statusLabels[status] || status)}
+${showQr ? `
QRIS
` : ''} +
+${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 }) %> +
+
+
QR-Custom Payment
+
+ Admin + Logout +
+
+
+ <% if (msg) { %> +
+ <%= msg.text %> +
+ <% } %> + + <% if (typeof hasQrisPayload !== 'undefined' && !hasQrisPayload) { %> +
QRIS statis belum diatur. Isi QRIS static payload di Pengaturan agar QR bisa dibuat.
+ <% } %> + +
+
Buat QR Nominal Custom
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
Pembayaran masuk
<%= stats.paidCount %>
Rp <%= Number(stats.paidTotal||0).toLocaleString('id-ID') %>
+
Menunggu (pending)
<%= stats.pendingCount %>
+
Warning (lewat 20 menit)
<%= stats.warningCount %>
+
Ditolak / refund
<%= stats.rejectedCount %>
+
+ + <% if (detail) { %> +
+
<%= detail.title %> — Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %>
+
+
+ <% if (detail.status === 'paid' || detail.status === 'rejected') { %> +
QR tidak aktif — status: <%= detail.status %>
+ <% } else { %> + QRIS +
+ <% } %> +
+
+
Status: <%= detail.status %>
+
Nominal dasar: Rp <%= Number(detail.amount||0).toLocaleString('id-ID') %>
+
Kode unik: <%= detail.unique_code %>
+
Nominal bayar: Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %>
+
Kategori: <%= detail.category || '-' %>
+
Dibuat: <%= detail.created_at %>
+
Kedaluwarsa: <%= detail.expires_at || '-' %>
+ <% if (detail.note) { %>
Catatan: <%= detail.note %>
<% } %> +
Link share:
+ +
+
+ + Buka +
+
+ <% if (detail.status !== 'paid') { %> +
+ + +
+ <% } %> + <% if (detail.status === 'warning') { %> +
+ +
+ <% } %> + <% if (detail.status !== 'paid' && detail.status !== 'rejected') { %> +
+ + +
+ <% } %> +
+
+
+
+ <% } %> + +
+
Histori QR Custom
+
+ + + + <% if (!rows || rows.length === 0) { %> + + <% } %> + <% rows.forEach(function(r) { %> + + + + + + + + + + <% }); %> + +
JudulKategoriNominalStatusDibuatKedaluwarsaAksi
Belum ada QR custom.
<%= r.title %><% if (r.note) { %>
<%= r.note %>
<% } %>
<%= r.category || '-' %>Rp <%= Number(r.amount_unique||0).toLocaleString('id-ID') %> + <%= r.status %> + <%= r.created_at %><%= r.expires_at || '-' %> + + <% if (r.status !== 'paid' && r.status !== 'rejected') { %> +
+ +
+ <% } %> +
+
+
+
+
+ + + diff --git a/views/admin/whatsapp.ejs b/views/admin/whatsapp.ejs index fec2ea4..299be69 100644 --- a/views/admin/whatsapp.ejs +++ b/views/admin/whatsapp.ejs @@ -219,12 +219,22 @@
+ +
+ + +
- +
-
Dibuat otomatis (32 byte). Samakan dengan WHATSAPP_WEBHOOK_SECRET di GOWA.
+
+ + +
+
+
Auto Generate: secret dibuat & dikunci otomatis. Manual: isi sendiri lalu klik Simpan Secret. Samakan dengan WHATSAPP_WEBHOOK_SECRET di GOWA.
@@ -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 = ' 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;