fix(qris): selaras skema hangus, satu tagihan terbaru
All checks were successful
CI / test (push) Successful in 5m10s

- batalkan pelunasan multi-tagihan: tagihan lama otomatis void saat tagihan baru terbit
- QRIS manual memakai tagihan terbaru, bukan total tunggakan
- versi 0.5.6
This commit is contained in:
oxpoe
2026-11-05 13:19:14 +07:00
parent a78e6b766a
commit d68cc1d664
7 changed files with 22 additions and 30 deletions

View File

@@ -2,10 +2,18 @@
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.5.6] — 2026-11-05 — "qris-hangus"
### Diperbaiki
- **QRIS mengikuti skema hangus**: nominal unik hanya untuk **satu tagihan** (tagihan terbaru). Tagihan lama otomatis `void` saat tagihan baru terbit, jadi tidak ada tunggakan menumpuk.
- Kirim tagihan QRIS manual memakai tagihan **terbaru** (sebelumnya bisa mengambil tagihan lama).
### Catatan
- Nonaktif otomatis setelah **3 bulan** menunggak dan hapus akun pada bulan berikutnya tetap berjalan sesuai pengaturan `auto_deactivate_months` / `auto_delete_overdue_enabled`.
## [0.5.5] — 2026-11-05 — "qris-fix" ## [0.5.5] — 2026-11-05 — "qris-fix"
### Diperbaiki ### Diperbaiki
- **QRIS multi-tagihan**: pembayaran dengan nominal total beberapa tagihan kini melunaskan **semua** tagihan pelanggan (sebelumnya hanya invoice yang menyimpan kode unik, sisanya tetap menunggak).
- **Kode unik bentrok**: pengecekan nominal unik sekarang mencakup invoice, voucher, dan donasi sekaligus — mencegah pembayaran dianggap ambigu oleh webhook (dulu bisa tidak diproses sama sekali). - **Kode unik bentrok**: pengecekan nominal unik sekarang mencakup invoice, voucher, dan donasi sekaligus — mencegah pembayaran dianggap ambigu oleh webhook (dulu bisa tidak diproses sama sekali).
- **Kirim tagihan QRIS manual**: gambar QR benar-benar terlampir (path `qrcode` tidak lagi hardcoded ke `/opt/billing-rtrw`), nominal di pesan memakai nominal unik, dan placeholder `{{qris_kode}}` didukung. - **Kirim tagihan QRIS manual**: gambar QR benar-benar terlampir (path `qrcode` tidak lagi hardcoded ke `/opt/billing-rtrw`), nominal di pesan memakai nominal unik, dan placeholder `{{qris_kode}}` didukung.
- **Kirim tagihan manual** memilih tagihan paling lama lebih dulu (sebelumnya tagihan terbaru). - **Kirim tagihan manual** memilih tagihan paling lama lebih dulu (sebelumnya tagihan terbaru).

View File

@@ -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.4_wa--notice-ADFF2F" alt="Versi 0.5.4 (wa-notice)"> <img src="https://img.shields.io/badge/versi-0.5.6_qris--hangus-ADFF2F" alt="Versi 0.5.6 (qris-hangus)">
<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>

View File

@@ -1011,21 +1011,7 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
const custId = Number(inv.customer_id || 0); const custId = Number(inv.customer_id || 0);
if (invId > 0) { if (invId > 0) {
const noteLine = `AUTO-QRIS: cocok nominal unik Rp ${amount} (service=${String(service || '-')}, notif=${notifId || '-'})`; const noteLine = `AUTO-QRIS: cocok nominal unik Rp ${amount} (service=${String(service || '-')}, notif=${notifId || '-'})`;
// QRIS multi-tagihan: nominal = total semua tagihan + kode unik yang disimpan
// di salah satu invoice. Lunaskan semuanya, jangan hanya invoice pemilik kode.
const unpaidRows = custId > 0
? db.prepare("SELECT id, amount FROM invoices WHERE customer_id=? AND status='unpaid' ORDER BY period_year ASC, period_month ASC, id ASC").all(custId)
: [];
const totalUnpaid = unpaidRows.reduce((sum, row) => sum + (Number(row.amount) || 0), 0);
const qrisCode = Number(inv.qris_unique_code || 0) || 0;
if (qrisCode > 0 && unpaidRows.length > 1 && totalUnpaid + qrisCode === Number(amount)) {
const noteBundle = `${noteLine} — lunas ${unpaidRows.length} tagihan`;
const payOne = db.prepare(`UPDATE invoices SET status='paid', paid_at=NOW_LOCAL(), paid_by_name=?, notes=CASE WHEN notes IS NULL OR TRIM(notes) = '' THEN ? ELSE notes || '\n' || ? END, qris_paid_notif_id=? WHERE id=? AND status='unpaid'`);
for (const row of unpaidRows) payOne.run('QRIS', noteBundle, noteBundle, notifId || null, row.id);
logger.info(`[WEBHOOK][payment-notif] MATCH bundle invoice=${invId} invoices=${unpaidRows.length} total=${totalUnpaid} amount=${amount}`);
} else {
markInvoicePaidAppendNote.run('QRIS', noteLine, noteLine, notifId || null, invId); markInvoicePaidAppendNote.run('QRIS', noteLine, noteLine, notifId || null, invId);
}
matchedInvoiceId = invId; matchedInvoiceId = invId;
if (notifId) { if (notifId) {

View File

@@ -1,6 +1,6 @@
{ {
"name": "open-isp", "name": "open-isp",
"version": "0.5.5", "version": "0.5.6",
"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": {

View File

@@ -40,8 +40,8 @@ function spintax(t) {
}); });
} }
function buildMessage(tplRaw, c, inv, qr, totalAmount) { function buildMessage(tplRaw, c, inv, qr) {
const amount = Number(totalAmount || inv.amount || 0); const amount = Number(inv.amount || 0);
const qrisAmount = Number((qr && qr.amount) || 0) || amount; const qrisAmount = Number((qr && qr.amount) || 0) || amount;
const qrisCode = Number((qr && qr.uniqueCode) || 0) || 0; const qrisCode = Number((qr && qr.uniqueCode) || 0) || 0;
const qrisAttached = !!(qr && qr.payload); const qrisAttached = !!(qr && qr.payload);
@@ -76,9 +76,8 @@ function buildTargets(isolateDay) {
try { invs = billingSvc.getUnpaidInvoicesByCustomerId(c.id) || []; } catch (e) {} try { invs = billingSvc.getUnpaidInvoicesByCustomerId(c.id) || []; } catch (e) {}
if (!invs.length) continue; if (!invs.length) continue;
if (!c.phone || String(c.phone).replace(/\D/g, '').length < 9) continue; if (!c.phone || String(c.phone).replace(/\D/g, '').length < 9) continue;
// invoice terurut dari periode terlama; tagih yang paling lama dulu // skema hangus: hanya tagihan terbaru yang valid; tagihan lama otomatis void saat tagihan baru terbit
const total = invs.reduce((sum, row) => sum + (Number(row.amount) || 0), 0); targets.push({ c, inv: invs[invs.length - 1] });
targets.push({ c, inv: invs[0], total });
} }
return targets; return targets;
} }
@@ -119,10 +118,10 @@ async function start(opts = {}) {
(async () => { (async () => {
for (let i = 0; i < targets.length; i++) { for (let i = 0; i < targets.length; i++) {
if (STATUS.stopped) break; if (STATUS.stopped) break;
const { c, inv, total } = targets[i]; const { c, inv } = targets[i];
try { try {
const qr = (isQris && ensureInvoiceQris) ? (ensureInvoiceQris(inv, total) || {}) : null; const qr = (isQris && ensureInvoiceQris) ? (ensureInvoiceQris(inv) || {}) : null;
const caption = buildMessage(tplRaw, c, inv, qr, total); const caption = buildMessage(tplRaw, c, inv, qr);
if (isQris && QRCode && qr && qr.payload) { if (isQris && QRCode && qr && qr.payload) {
const url = await QRCode.toDataURL(qr.payload, { errorCorrectionLevel: 'M', margin: 1, width: 520 }); const url = await QRCode.toDataURL(qr.payload, { errorCorrectionLevel: 'M', margin: 1, width: 520 });
const buf = Buffer.from(String(url).split(',')[1], 'base64'); const buf = Buffer.from(String(url).split(',')[1], 'base64');

View File

@@ -30,11 +30,10 @@ function isQrisAmountTaken(amount, opts = {}) {
return false; return false;
} }
function ensureInvoiceQris(inv, baseAmountOverride) { function ensureInvoiceQris(inv) {
const settings = getSettings() || {}; const settings = getSettings() || {};
const invId = Number((inv && inv.id) || 0); const invId = Number((inv && inv.id) || 0);
// baseAmountOverride dipakai bila nominal QRIS = total beberapa tagihan const base = Number((inv && inv.amount) || 0);
const base = Number(baseAmountOverride || (inv && inv.amount) || 0);
if (!invId || base <= 0) return { uniqueCode: 0, amount: base, payload: '' }; if (!invId || base <= 0) return { uniqueCode: 0, amount: base, payload: '' };
let code = Number(inv.qris_unique_code || 0); let code = Number(inv.qris_unique_code || 0);

View File

@@ -1 +1 @@
0.5.5 0.5.6