feat: QR-Custom Payment + mode webhook secret GOWA
All checks were successful
CI / test (push) Successful in 1m35s
All checks were successful
CI / test (push) Successful in 1m35s
- tabel custom_qr_payments, service, halaman admin + publik share - kode unik 20 menit, warning bila bayar setelah batas, manual pay/accept/reject - webhook payment-notif mencocokkan nominal custom - menu sidebar QR-Custom Payment - GOWA secret: auto generate (locked) / manual (simpan lalu locked, ubah) - versi 0.6.0
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
|
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"
|
## [0.5.20] — 2026-11-06 — "copy-secret"
|
||||||
|
|
||||||
### Ditambahkan
|
### Ditambahkan
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
|
<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/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">
|
<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>
|
<a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a>
|
||||||
|
|||||||
129
app-customer.js
129
app-customer.js
@@ -333,6 +333,17 @@ const updateWebhookPaymentNotifMatchDonation = db.prepare(`
|
|||||||
SET matched_donation_order_id=?
|
SET matched_donation_order_id=?
|
||||||
WHERE 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(`
|
const markInvoicePaidAppendNote = db.prepare(`
|
||||||
UPDATE invoices
|
UPDATE invoices
|
||||||
@@ -998,14 +1009,17 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
|
|||||||
let matchedInvoiceId = null;
|
let matchedInvoiceId = null;
|
||||||
let matchedVoucherOrderId = null;
|
let matchedVoucherOrderId = null;
|
||||||
let matchedDonationOrderId = null;
|
let matchedDonationOrderId = null;
|
||||||
|
let matchedCustomQrId = null;
|
||||||
if (amount != null) {
|
if (amount != null) {
|
||||||
try {
|
try {
|
||||||
const invCandidates = selectInvoiceByUniqueAmount.all(amount);
|
const invCandidates = selectInvoiceByUniqueAmount.all(amount);
|
||||||
const vCandidates = selectVoucherOrderByUniqueAmount.all(amount);
|
const vCandidates = selectVoucherOrderByUniqueAmount.all(amount);
|
||||||
const dCandidates = selectDonationOrderByUniqueAmount.all(amount);
|
const dCandidates = selectDonationOrderByUniqueAmount.all(amount);
|
||||||
|
const cqCandidates = selectCustomQrByUniqueAmount.all(amount);
|
||||||
const totalCandidates = (Array.isArray(invCandidates) ? invCandidates.length : 0) +
|
const totalCandidates = (Array.isArray(invCandidates) ? invCandidates.length : 0) +
|
||||||
(Array.isArray(vCandidates) ? vCandidates.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 (totalCandidates === 1) {
|
||||||
if (Array.isArray(invCandidates) && invCandidates.length === 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}`);
|
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) {
|
} else if (totalCandidates > 1) {
|
||||||
const invIds = Array.isArray(invCandidates) ? invCandidates.map(x => x.id).join(',') : '';
|
const invIds = Array.isArray(invCandidates) ? invCandidates.map(x => x.id).join(',') : '';
|
||||||
const vIds = Array.isArray(vCandidates) ? vCandidates.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(',') : '';
|
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
|
// 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.
|
// (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);
|
const paidInv = selectPaidInvoiceByUniqueAmount.all(amount);
|
||||||
if (Array.isArray(paidInv) && paidInv.length === 1) {
|
if (Array.isArray(paidInv) && paidInv.length === 1) {
|
||||||
matchedInvoiceId = Number(paidInv[0].id || 0) || null;
|
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 = `<!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)
|
// Mount built-in ACS server endpoint (TR-069)
|
||||||
const acsServerService = require('./services/acsServerService');
|
const acsServerService = require('./services/acsServerService');
|
||||||
app.post('/acs', express.raw({ type: ['text/xml', 'application/soap+xml', 'application/xml', 'text/plain'], limit: '2mb' }), acsServerService.handleCwmpRequest);
|
app.post('/acs', express.raw({ type: ['text/xml', 'application/soap+xml', 'application/xml', 'text/plain'], limit: '2mb' }), acsServerService.handleCwmpRequest);
|
||||||
|
|||||||
@@ -1069,6 +1069,42 @@ try {
|
|||||||
console.error('Failed to migrate public_donation_orders:', e);
|
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)
|
// Safe migration: automation_runs (riwayat otomatisasi pengiriman tagihan)
|
||||||
try {
|
try {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-isp",
|
"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)",
|
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
|
||||||
"main": "app-customer.js",
|
"main": "app-customer.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4013,6 +4013,70 @@ router.post('/sidebar-settings', requireAdminSession, restrictToAdmin, express.u
|
|||||||
res.redirect('/admin/sidebar-settings');
|
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) => {
|
router.get('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), (req, res) => {
|
||||||
let routers = [];
|
let routers = [];
|
||||||
try { routers = db.prepare('SELECT id, name FROM routers WHERE is_active = 1 ORDER BY name ASC').all(); } catch (_) {}
|
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) {}
|
try { gowaWebhookSecret = require('../config/settingsManager').ensureGowaWebhookSecret(); } catch (e) {}
|
||||||
const gowaSettings = {
|
const gowaSettings = {
|
||||||
gowa_mode: getSetting('gowa_mode', /^https:/i.test(String(gowaBaseUrl)) ? 'cloud' : 'local'),
|
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_base_url: gowaBaseUrl,
|
||||||
gowa_username: getSetting('gowa_username', ''),
|
gowa_username: getSetting('gowa_username', ''),
|
||||||
gowa_password: getSetting('gowa_password', ''),
|
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) => {
|
router.post('/whatsapp/test-gowa', requireAdminSession, express.json(), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { phone, message } = req.body;
|
const { phone, message } = req.body;
|
||||||
|
|||||||
177
services/customQrService.js
Normal file
177
services/customQrService.js
Normal 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
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ function isQrisAmountTaken(amount, opts = {}) {
|
|||||||
const exInv = Number(opts.excludeInvoiceId || 0);
|
const exInv = Number(opts.excludeInvoiceId || 0);
|
||||||
const exVoucher = Number(opts.excludeVoucherOrderId || 0);
|
const exVoucher = Number(opts.excludeVoucherOrderId || 0);
|
||||||
const exDonation = Number(opts.excludeDonationOrderId || 0);
|
const exDonation = Number(opts.excludeDonationOrderId || 0);
|
||||||
|
const exCustomQr = Number(opts.excludeCustomQrId || 0);
|
||||||
try {
|
try {
|
||||||
const inv = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('unpaid', amt, exInv);
|
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;
|
if (inv && inv.id) return true;
|
||||||
@@ -26,6 +27,8 @@ function isQrisAmountTaken(amount, opts = {}) {
|
|||||||
if (voc && voc.id) return true;
|
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);
|
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;
|
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 */ }
|
} catch (e) { /* tabel opsional belum ada */ }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '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: '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: '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: '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'] },
|
{ 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,
|
olts: STATE_HIDDEN,
|
||||||
whatsapp: STATE_VISIBLE,
|
whatsapp: STATE_VISIBLE,
|
||||||
automation: STATE_VISIBLE,
|
automation: STATE_VISIBLE,
|
||||||
|
custom_qr: STATE_VISIBLE,
|
||||||
broadcast: STATE_VISIBLE,
|
broadcast: STATE_VISIBLE,
|
||||||
whatsapp_live_chat: STATE_VISIBLE,
|
whatsapp_live_chat: STATE_VISIBLE,
|
||||||
promo_slides: STATE_VISIBLE,
|
promo_slides: STATE_VISIBLE,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.5.20
|
0.6.0
|
||||||
163
views/admin/custom_qr.ejs
Normal file
163
views/admin/custom_qr.ejs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="<%= lang || 'id' %>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title><%= title %> | <%= company %></title>
|
||||||
|
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
|
||||||
|
<link rel="stylesheet" href="/css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<%- include('partials/sidebar', { activePage: activePage || 'custom_qr', company }) %>
|
||||||
|
<div class="mw">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="tb-title"><button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button><i class="bi bi-qr-code-simple"></i> QR-Custom Payment</div>
|
||||||
|
<div class="tb-right">
|
||||||
|
<span class="adm-badge"><i class="bi bi-person-fill"></i> Admin</span>
|
||||||
|
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i> Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<% if (msg) { %>
|
||||||
|
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
|
||||||
|
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (typeof hasQrisPayload !== 'undefined' && !hasQrisPayload) { %>
|
||||||
|
<div class="alert alert-d"><i class="bi bi-exclamation-triangle"></i> QRIS statis belum diatur. Isi <b>QRIS static payload</b> di Pengaturan agar QR bisa dibuat.</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="card mb16">
|
||||||
|
<div class="card-hd"><h6><i class="bi bi-plus-circle"></i> Buat QR Nominal Custom</h6></div>
|
||||||
|
<div class="card-bd">
|
||||||
|
<form method="POST" action="/admin/custom-qr">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="fg" style="flex:2"><label class="fl">Judul *</label><input class="fc" name="title" required placeholder="mis. Pembayaran sewa tower / donasi perusahaan"></div>
|
||||||
|
<div class="fg"><label class="fl">Kategori</label><input class="fc" name="category" placeholder="mis. Sewa, Donasi, Lain-lain"></div>
|
||||||
|
<div class="fg"><label class="fl">Nominal (Rp) *</label><input class="fc" type="number" min="1" name="amount" required placeholder="100000"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="fg" style="flex:2"><label class="fl">Catatan (opsional)</label><input class="fc" name="note" placeholder="Keterangan untuk penerima link"></div>
|
||||||
|
<div class="fg" style="display:flex;align-items:flex-end"><button class="btn btn-p" type="submit"><i class="bi bi-qr-code"></i> Generate QR (20 menit)</button></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row mb16">
|
||||||
|
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Pembayaran masuk</div><div style="font-size:22px;font-weight:800"><%= stats.paidCount %></div><div class="muted" style="font-size:11px">Rp <%= Number(stats.paidTotal||0).toLocaleString('id-ID') %></div></div></div></div>
|
||||||
|
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Menunggu (pending)</div><div style="font-size:22px;font-weight:800;color:#fbbf24"><%= stats.pendingCount %></div></div></div></div>
|
||||||
|
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Warning (lewat 20 menit)</div><div style="font-size:22px;font-weight:800;color:#fb923c"><%= stats.warningCount %></div></div></div></div>
|
||||||
|
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Ditolak / refund</div><div style="font-size:22px;font-weight:800"><%= stats.rejectedCount %></div></div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (detail) { %>
|
||||||
|
<div class="card mb16">
|
||||||
|
<div class="card-hd"><h6><i class="bi bi-qr-code"></i> <%= detail.title %> — Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></h6></div>
|
||||||
|
<div class="card-bd" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:18px">
|
||||||
|
<div style="text-align:center">
|
||||||
|
<% if (detail.status === 'paid' || detail.status === 'rejected') { %>
|
||||||
|
<div style="padding:24px;border:1px dashed var(--border);border-radius:12px;color:var(--muted)">QR tidak aktif — status: <b><%= detail.status %></b></div>
|
||||||
|
<% } else { %>
|
||||||
|
<img src="/custom-qr/<%= detail.share_token %>/qr.png" alt="QRIS" style="max-width:240px;background:#fff;padding:10px;border-radius:12px">
|
||||||
|
<div id="cqTimer" data-expires="<%= detail.expires_at || '' %>" style="margin-top:8px;font-size:13px;color:#fbbf24"></div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;line-height:1.8">
|
||||||
|
<div><b>Status:</b> <%= detail.status %></div>
|
||||||
|
<div><b>Nominal dasar:</b> Rp <%= Number(detail.amount||0).toLocaleString('id-ID') %></div>
|
||||||
|
<div><b>Kode unik:</b> <%= detail.unique_code %></div>
|
||||||
|
<div><b>Nominal bayar:</b> Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></div>
|
||||||
|
<div><b>Kategori:</b> <%= detail.category || '-' %></div>
|
||||||
|
<div><b>Dibuat:</b> <%= detail.created_at %></div>
|
||||||
|
<div><b>Kedaluwarsa:</b> <%= detail.expires_at || '-' %></div>
|
||||||
|
<% if (detail.note) { %><div><b>Catatan:</b> <%= detail.note %></div><% } %>
|
||||||
|
<div style="margin-top:8px"><b>Link share:</b><br>
|
||||||
|
<input class="fc" readonly id="cqLink" value="<%= baseUrl %>/custom-qr/<%= detail.share_token %>" style="font-size:12px">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
|
||||||
|
<button class="btn btn-g btn-sm" onclick="copyCqLink()"><i class="bi bi-copy"></i> Salin Link</button>
|
||||||
|
<a class="btn btn-g btn-sm" href="<%= baseUrl %>/custom-qr/<%= detail.share_token %>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i> Buka</a>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">
|
||||||
|
<% if (detail.status !== 'paid') { %>
|
||||||
|
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/manual-paid" onsubmit="return confirm('Tandai pembayaran ini LUNAS secara manual?')">
|
||||||
|
<input type="hidden" name="note" value="Manual pay dari panel admin">
|
||||||
|
<button class="btn btn-p btn-sm"><i class="bi bi-check2-circle"></i> Manual Pay</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<% if (detail.status === 'warning') { %>
|
||||||
|
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/accept" onsubmit="return confirm('Terima pembayaran setelah 20 menit?')">
|
||||||
|
<button class="btn btn-p btn-sm"><i class="bi bi-check2"></i> Selesaikan</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<% if (detail.status !== 'paid' && detail.status !== 'rejected') { %>
|
||||||
|
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/reject" onsubmit="return confirm('Tolak / refund pembayaran ini?')">
|
||||||
|
<input type="hidden" name="note" value="Refund dari panel admin">
|
||||||
|
<button class="btn btn-g btn-sm"><i class="bi bi-x-circle"></i> Reject / Refund</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-hd"><h6><i class="bi bi-clock-history"></i> Histori QR Custom</h6></div>
|
||||||
|
<div class="card-bd" style="overflow:auto">
|
||||||
|
<table class="table" style="width:100%;font-size:12.5px">
|
||||||
|
<thead><tr><th>Judul</th><th>Kategori</th><th>Nominal</th><th>Status</th><th>Dibuat</th><th>Kedaluwarsa</th><th>Aksi</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<% if (!rows || rows.length === 0) { %>
|
||||||
|
<tr><td colspan="7" class="muted" style="text-align:center;padding:20px">Belum ada QR custom.</td></tr>
|
||||||
|
<% } %>
|
||||||
|
<% rows.forEach(function(r) { %>
|
||||||
|
<tr>
|
||||||
|
<td><b><%= r.title %></b><% if (r.note) { %><div class="muted" style="font-size:11px"><%= r.note %></div><% } %></td>
|
||||||
|
<td><%= r.category || '-' %></td>
|
||||||
|
<td>Rp <%= Number(r.amount_unique||0).toLocaleString('id-ID') %></td>
|
||||||
|
<td>
|
||||||
|
<span style="font-weight:700;color:<%= r.status==='paid'?'#4ade80':(r.status==='warning'?'#fb923c':(r.status==='rejected'?'#94a3b8':(r.status==='expired'?'#f87171':'#fbbf24'))) %>"><%= r.status %></span>
|
||||||
|
</td>
|
||||||
|
<td><%= r.created_at %></td>
|
||||||
|
<td><%= r.expires_at || '-' %></td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<a class="btn btn-g btn-sm" href="/admin/custom-qr?id=<%= r.id %>"><i class="bi bi-eye"></i></a>
|
||||||
|
<% if (r.status !== 'paid' && r.status !== 'rejected') { %>
|
||||||
|
<form method="POST" action="/admin/custom-qr/<%= r.id %>/manual-paid" style="display:inline" onsubmit="return confirm('Tandai LUNAS manual?')">
|
||||||
|
<button class="btn btn-g btn-sm" title="Manual pay"><i class="bi bi-check2-circle"></i></button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function copyCqLink() {
|
||||||
|
var el = document.getElementById('cqLink');
|
||||||
|
if (!el) return;
|
||||||
|
try { el.select(); document.execCommand('copy'); } catch (e) {}
|
||||||
|
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(el.value).catch(function () {});
|
||||||
|
}
|
||||||
|
(function () {
|
||||||
|
var el = document.getElementById('cqTimer');
|
||||||
|
if (!el) return;
|
||||||
|
var exp = el.getAttribute('data-expires');
|
||||||
|
function tick() {
|
||||||
|
if (!exp) { el.textContent = ''; return; }
|
||||||
|
var d = new Date(exp).getTime() - Date.now();
|
||||||
|
if (d <= 0) { el.textContent = 'Lewat 20 menit — pembayaran tetap dicek.'; return; }
|
||||||
|
var m = Math.floor(d / 60000), s = Math.floor((d % 60000) / 1000);
|
||||||
|
el.textContent = 'Sisa waktu: ' + m + ' menit ' + s + ' detik';
|
||||||
|
}
|
||||||
|
tick(); setInterval(tick, 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -219,12 +219,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fg" style="flex:1;">
|
<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>
|
<label class="fl">Webhook Secret (HMAC)</label>
|
||||||
<div style="display:flex;gap:8px;">
|
<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>
|
<button type="button" class="btn btn-g" onclick="copyGowaSecret(this)" title="Salin webhook secret"><i class="bi bi-copy"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Dibuat otomatis (32 byte). Samakan dengan <code>WHATSAPP_WEBHOOK_SECRET</code> di GOWA.</div>
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
||||||
|
<button type="button" class="btn btn-p btn-sm" id="gsm-btn-save" onclick="gowaSecretSave(this)"><i class="bi bi-save"></i> Simpan Secret</button>
|
||||||
|
<button type="button" class="btn btn-g btn-sm" id="gsm-btn-edit" onclick="gowaSecretEdit()"><i class="bi bi-unlock"></i> Ubah</button>
|
||||||
|
</div>
|
||||||
|
<div id="gowa_secret_status" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:4px">Auto Generate: secret dibuat & dikunci otomatis. Manual: isi sendiri lalu klik Simpan Secret. Samakan dengan <code>WHATSAPP_WEBHOOK_SECRET</code> di GOWA.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,6 +360,73 @@
|
|||||||
render();
|
render();
|
||||||
_gowaQrTick = setInterval(function () { remaining--; if (remaining < 0) remaining = 0; render(); }, 1000);
|
_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) {
|
function copyGowaSecret(btn) {
|
||||||
var el = document.getElementById('gowa_webhook_secret');
|
var el = document.getElementById('gowa_webhook_secret');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user