fix(qris): selaras skema hangus, satu tagihan terbaru
All checks were successful
CI / test (push) Successful in 5m10s
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:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -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).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.5.5
|
0.5.6
|
||||||
Reference in New Issue
Block a user