fix(qris): kode unik lintas order + pelunasan multi-tagihan; fix kirim tagihan manual
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
- webhook QRIS: nominal total tagihan + kode -> lunaskan semua tagihan pelanggan
- isQrisAmountTaken() cek invoice+voucher+donasi (cegah ambigu)
- billingSendService: require('qrcode'), nominal unik + {{qris_kode}}, fallback link
- pilih tagihan terlama; pesan pengingat manual yang jujur saat di-skip
- versi 0.5.5
This commit is contained in:
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
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.5] — 2026-11-05 — "qris-fix"
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
- **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).
|
||||||
|
- Pesan **pengingat manual** kini jujur saat tidak dikirim (WA/notifikasi OFF, gateway belum siap, atau tidak ada target hari ini), bukan “dijalankan: target 0”.
|
||||||
|
|
||||||
## [0.5.4] — 2026-11-05 — "wa-notice"
|
## [0.5.4] — 2026-11-05 — "wa-notice"
|
||||||
|
|
||||||
### Ditambahkan
|
### Ditambahkan
|
||||||
|
|||||||
@@ -1011,7 +1011,21 @@ 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 || '-'})`;
|
||||||
markInvoicePaidAppendNote.run('QRIS', noteLine, noteLine, notifId || null, invId);
|
// 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);
|
||||||
|
}
|
||||||
matchedInvoiceId = invId;
|
matchedInvoiceId = invId;
|
||||||
|
|
||||||
if (notifId) {
|
if (notifId) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-isp",
|
"name": "open-isp",
|
||||||
"version": "0.5.4",
|
"version": "0.5.5",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const _jimpMod = require('jimp');
|
|||||||
const Jimp = _jimpMod.Jimp || _jimpMod;
|
const Jimp = _jimpMod.Jimp || _jimpMod;
|
||||||
const jsQR = require('jsqr');
|
const jsQR = require('jsqr');
|
||||||
const qrisUtil = require('../utils/qrisUtil');
|
const qrisUtil = require('../utils/qrisUtil');
|
||||||
|
const qrisUniq = require('../services/invoiceQrisService');
|
||||||
const { MultiFormatReader, BarcodeFormat, DecodeHintType, BinaryBitmap, HybridBinarizer, RGBLuminanceSource } = require('@zxing/library');
|
const { MultiFormatReader, BarcodeFormat, DecodeHintType, BinaryBitmap, HybridBinarizer, RGBLuminanceSource } = require('@zxing/library');
|
||||||
const QRCode = require('qrcode');
|
const QRCode = require('qrcode');
|
||||||
const acsPortal = require('./acsPortal');
|
const acsPortal = require('./acsPortal');
|
||||||
@@ -3105,7 +3106,6 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
|
|||||||
const baseAmount = Number(inv.amount || 0);
|
const baseAmount = Number(inv.amount || 0);
|
||||||
if (!Number.isFinite(baseAmount) || baseAmount <= 0) throw new Error('Nominal tagihan tidak valid');
|
if (!Number.isFinite(baseAmount) || baseAmount <= 0) throw new Error('Nominal tagihan tidak valid');
|
||||||
|
|
||||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
|
||||||
const update = db.prepare(`
|
const update = db.prepare(`
|
||||||
UPDATE invoices
|
UPDATE invoices
|
||||||
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
|
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
|
||||||
@@ -3118,7 +3118,7 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
|
|||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
const code = 1 + Math.floor(Math.random() * 999);
|
const code = 1 + Math.floor(Math.random() * 999);
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
@@ -3128,7 +3128,7 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
|
|||||||
if (!chosenAmount) {
|
if (!chosenAmount) {
|
||||||
for (let code = 1; code <= 999; code++) {
|
for (let code = 1; code <= 999; code++) {
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
@@ -3202,7 +3202,6 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
|||||||
if (hasStaticQris && String(inv.status) === 'unpaid' && (!qrisAmountUnique || !qrisCode || (qrisAmountUnique - qrisCode !== baseAmount))) {
|
if (hasStaticQris && String(inv.status) === 'unpaid' && (!qrisAmountUnique || !qrisCode || (qrisAmountUnique - qrisCode !== baseAmount))) {
|
||||||
const invId = Number(inv.id);
|
const invId = Number(inv.id);
|
||||||
if (Number.isFinite(invId) && invId > 0 && Number.isFinite(baseAmount) && baseAmount > 0) {
|
if (Number.isFinite(invId) && invId > 0 && Number.isFinite(baseAmount) && baseAmount > 0) {
|
||||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
|
||||||
const update = db.prepare(`
|
const update = db.prepare(`
|
||||||
UPDATE invoices
|
UPDATE invoices
|
||||||
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
|
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
|
||||||
@@ -3214,7 +3213,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
|||||||
|
|
||||||
if (chosenCode > 0) {
|
if (chosenCode > 0) {
|
||||||
const pAmt = baseAmount + chosenCode;
|
const pAmt = baseAmount + chosenCode;
|
||||||
if (!exists.get('unpaid', pAmt, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||||
chosenAmount = pAmt;
|
chosenAmount = pAmt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3223,7 +3222,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
|||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
const code = 1 + Math.floor(Math.random() * 999);
|
const code = 1 + Math.floor(Math.random() * 999);
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
@@ -3233,7 +3232,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
|||||||
if (!chosenAmount) {
|
if (!chosenAmount) {
|
||||||
for (let code = 1; code <= 999; code++) {
|
for (let code = 1; code <= 999; code++) {
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
@@ -7235,8 +7234,14 @@ router.post('/whatsapp/auto-billing', requireAdminSession, express.urlencoded({
|
|||||||
router.post('/whatsapp/run-reminder-now', requireAdminSession, async (req, res) => {
|
router.post('/whatsapp/run-reminder-now', requireAdminSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const r = await require('../services/cronService').runAutoBillingReminder();
|
const r = await require('../services/cronService').runAutoBillingReminder();
|
||||||
|
const skipLabels = {
|
||||||
|
disabled: 'pengingat otomatis sedang OFF',
|
||||||
|
wa_disabled: 'WhatsApp atau notifikasi tagihan sedang OFF',
|
||||||
|
gateway: 'gateway WhatsApp belum siap',
|
||||||
|
no_targets: 'tidak ada pelanggan pada jadwal pengingat hari ini'
|
||||||
|
};
|
||||||
const msg = (r && r.skipped)
|
const msg = (r && r.skipped)
|
||||||
? ('Pengingat dilewati (' + r.skipped + '). Pastikan opsi otomatis & WA aktif.')
|
? ('Pengingat tidak dikirim: ' + (skipLabels[r.skipped] || r.skipped) + '.')
|
||||||
: ('Pengingat dijalankan: target ' + ((r && r.target) || 0) + ', terkirim ' + ((r && r.sent) || 0) + ', gagal ' + ((r && r.failed) || 0) + '.');
|
: ('Pengingat dijalankan: target ' + ((r && r.target) || 0) + ', terkirim ' + ((r && r.sent) || 0) + ', gagal ' + ((r && r.failed) || 0) + '.');
|
||||||
return res.json({ ok: true, message: msg, data: r || null });
|
return res.json({ ok: true, message: msg, data: r || null });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const adminSvc = require('../services/adminService');
|
|||||||
const techSvc = require('../services/techService');
|
const techSvc = require('../services/techService');
|
||||||
const mikrotikService = require('../services/mikrotikService');
|
const mikrotikService = require('../services/mikrotikService');
|
||||||
const qrisUtil = require('../utils/qrisUtil');
|
const qrisUtil = require('../utils/qrisUtil');
|
||||||
|
const qrisUniq = require('../services/invoiceQrisService');
|
||||||
const QRCode = require('qrcode');
|
const QRCode = require('qrcode');
|
||||||
|
|
||||||
// Helper Token JWT / HMAC
|
// Helper Token JWT / HMAC
|
||||||
@@ -3675,7 +3676,6 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
|||||||
return { uniqueCode: currentCode, amountUnique: currentAmount };
|
return { uniqueCode: currentCode, amountUnique: currentAmount };
|
||||||
}
|
}
|
||||||
|
|
||||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
|
||||||
let chosenCode = 0;
|
let chosenCode = 0;
|
||||||
let chosenAmount = 0;
|
let chosenAmount = 0;
|
||||||
|
|
||||||
@@ -3683,7 +3683,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
|||||||
if (custId > 0) {
|
if (custId > 0) {
|
||||||
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
|
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
|
||||||
const prefAmount = baseAmount + prefCode;
|
const prefAmount = baseAmount + prefCode;
|
||||||
if (!exists.get('unpaid', prefAmount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(prefAmount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = prefCode;
|
chosenCode = prefCode;
|
||||||
chosenAmount = prefAmount;
|
chosenAmount = prefAmount;
|
||||||
}
|
}
|
||||||
@@ -3693,7 +3693,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
|||||||
if (!chosenAmount) {
|
if (!chosenAmount) {
|
||||||
for (let code = 1; code <= 499; code++) {
|
for (let code = 1; code <= 499; code++) {
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
@@ -3705,7 +3705,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
|||||||
if (!chosenAmount) {
|
if (!chosenAmount) {
|
||||||
for (let code = 500; code <= 999; code++) {
|
for (let code = 500; code <= 999; code++) {
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const QRCode = require('qrcode');
|
|||||||
const _jimpMod = require('jimp');
|
const _jimpMod = require('jimp');
|
||||||
const Jimp = _jimpMod.Jimp || _jimpMod;
|
const Jimp = _jimpMod.Jimp || _jimpMod;
|
||||||
const qrisUtil = require('../utils/qrisUtil');
|
const qrisUtil = require('../utils/qrisUtil');
|
||||||
|
const qrisUniq = require('../services/invoiceQrisService');
|
||||||
const whatsappService = require('../services/whatsappService');
|
const whatsappService = require('../services/whatsappService');
|
||||||
const { BinaryBitmap, HybridBinarizer, RGBLuminanceSource, MultiFormatReader, BarcodeFormat, DecodeHintType } = require('@zxing/library');
|
const { BinaryBitmap, HybridBinarizer, RGBLuminanceSource, MultiFormatReader, BarcodeFormat, DecodeHintType } = require('@zxing/library');
|
||||||
let loginRateLimiter = (req, res, next) => next();
|
let loginRateLimiter = (req, res, next) => next();
|
||||||
@@ -437,16 +438,10 @@ function getBaseUrl(req, settings) {
|
|||||||
function isQrisAmountAvailable(amount, opts = {}) {
|
function isQrisAmountAvailable(amount, opts = {}) {
|
||||||
const amt = Number(amount || 0);
|
const amt = Number(amount || 0);
|
||||||
if (!Number.isFinite(amt) || amt <= 0) return false;
|
if (!Number.isFinite(amt) || amt <= 0) return false;
|
||||||
const excludeInvoiceId = Number(opts.excludeInvoiceId || 0);
|
return !qrisUniq.isQrisAmountTaken(amt, {
|
||||||
const excludeVoucherOrderId = Number(opts.excludeVoucherOrderId || 0);
|
excludeInvoiceId: opts.excludeInvoiceId,
|
||||||
|
excludeVoucherOrderId: opts.excludeVoucherOrderId
|
||||||
const inv = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('unpaid', amt, excludeInvoiceId);
|
});
|
||||||
if (inv && inv.id) return false;
|
|
||||||
|
|
||||||
const ord = db.prepare('SELECT id FROM public_voucher_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, excludeVoucherOrderId);
|
|
||||||
if (ord && ord.id) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/qris/static.jpg', async (req, res) => {
|
router.get('/qris/static.jpg', async (req, res) => {
|
||||||
|
|||||||
@@ -40,15 +40,19 @@ function spintax(t) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessage(tplRaw, c, inv) {
|
function buildMessage(tplRaw, c, inv, qr) {
|
||||||
const amount = Number(inv.amount || 0);
|
const amount = Number(inv.amount || 0);
|
||||||
|
const qrisAmount = Number((qr && qr.amount) || 0) || amount;
|
||||||
|
const qrisCode = Number((qr && qr.uniqueCode) || 0) || 0;
|
||||||
|
const qrisAttached = !!(qr && qr.payload);
|
||||||
const period = inv.period_month + '/' + inv.period_year;
|
const period = inv.period_month + '/' + inv.period_year;
|
||||||
return spintax(String(tplRaw || '')
|
return spintax(String(tplRaw || '')
|
||||||
.replace(/{{id_pelanggan}}/gi, c.customer_code || ('ID:' + c.id))
|
.replace(/{{id_pelanggan}}/gi, c.customer_code || ('ID:' + c.id))
|
||||||
.replace(/{{nama}}/gi, c.name || 'Pelanggan')
|
.replace(/{{nama}}/gi, c.name || 'Pelanggan')
|
||||||
.replace(/{{paket}}/gi, inv.package_name || '-')
|
.replace(/{{paket}}/gi, inv.package_name || '-')
|
||||||
.replace(/{{tagihan}}/gi, amount.toLocaleString('id-ID'))
|
.replace(/{{tagihan}}/gi, amount.toLocaleString('id-ID'))
|
||||||
.replace(/{{qris_nominal}}/gi, amount.toLocaleString('id-ID'))
|
.replace(/{{qris_nominal}}/gi, qrisAmount.toLocaleString('id-ID'))
|
||||||
|
.replace(/{{qris_kode}}/gi, String(qrisCode).padStart(3, '0'))
|
||||||
.replace(/{{rincian}}/gi, period)
|
.replace(/{{rincian}}/gi, period)
|
||||||
.replace(/{{periode}}/gi, period)
|
.replace(/{{periode}}/gi, period)
|
||||||
.replace(/{{bulan_nunggak}}/gi, String(c.unpaid_streak || 0))
|
.replace(/{{bulan_nunggak}}/gi, String(c.unpaid_streak || 0))
|
||||||
@@ -57,7 +61,7 @@ function buildMessage(tplRaw, c, inv) {
|
|||||||
.replace(/{{link_portal}}/gi, portalLink())
|
.replace(/{{link_portal}}/gi, portalLink())
|
||||||
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
|
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
|
||||||
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
|
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
|
||||||
.replace(/{{qris_qr}}/gi, 'QRIS terlampir'));
|
.replace(/{{qris_qr}}/gi, qrisAttached ? 'QRIS terlampir' : ('Portal Pelanggan: ' + portalLink())));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTargets(isolateDay) {
|
function buildTargets(isolateDay) {
|
||||||
@@ -72,7 +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;
|
||||||
targets.push({ c, inv: invs[invs.length - 1] });
|
// invoice terurut dari periode terlama; tagih yang paling lama dulu
|
||||||
|
targets.push({ c, inv: invs[0] });
|
||||||
}
|
}
|
||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
@@ -108,23 +113,19 @@ async function start(opts = {}) {
|
|||||||
|
|
||||||
const waNotify = require('./waNotifyService');
|
const waNotify = require('./waNotifyService');
|
||||||
let QRCode = null, ensureInvoiceQris = null;
|
let QRCode = null, ensureInvoiceQris = null;
|
||||||
if (isQris) { try { QRCode = require('/opt/billing-rtrw/node_modules/qrcode'); } catch (e) {} try { ensureInvoiceQris = require('./invoiceQrisService').ensureInvoiceQris; } catch (e) {} }
|
if (isQris) { try { QRCode = require('qrcode'); } catch (e) {} try { ensureInvoiceQris = require('./invoiceQrisService').ensureInvoiceQris; } catch (e) {} }
|
||||||
|
|
||||||
(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 } = targets[i];
|
const { c, inv } = targets[i];
|
||||||
try {
|
try {
|
||||||
const caption = buildMessage(tplRaw, c, inv);
|
const qr = (isQris && ensureInvoiceQris) ? (ensureInvoiceQris(inv) || {}) : null;
|
||||||
if (isQris && QRCode && ensureInvoiceQris) {
|
const caption = buildMessage(tplRaw, c, inv, qr);
|
||||||
const qr = ensureInvoiceQris(inv) || {};
|
if (isQris && QRCode && qr && qr.payload) {
|
||||||
if (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');
|
await waNotify.sendImage(c.phone, buf, caption);
|
||||||
await waNotify.sendImage(c.phone, buf, caption);
|
|
||||||
} else {
|
|
||||||
await waNotify.sendText(c.phone, caption);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
await waNotify.sendText(c.phone, caption);
|
await waNotify.sendText(c.phone, caption);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const usageSvc = require('./usageService');
|
|||||||
const { getSetting } = require('../config/settingsManager');
|
const { getSetting } = require('../config/settingsManager');
|
||||||
const db = require('../config/database');
|
const db = require('../config/database');
|
||||||
const qrisUtil = require('../utils/qrisUtil');
|
const qrisUtil = require('../utils/qrisUtil');
|
||||||
|
const qrisUniq = require('./invoiceQrisService');
|
||||||
const { formatUnpaidInvoicesSummary } = require('./whatsappService');
|
const { formatUnpaidInvoicesSummary } = require('./whatsappService');
|
||||||
|
|
||||||
// Helper: Random delay generator untuk smart rate limiting
|
// Helper: Random delay generator untuk smart rate limiting
|
||||||
@@ -60,7 +61,7 @@ async function runAutoBillingReminder() {
|
|||||||
const enabled = getSetting('whatsapp_auto_billing_enabled', false);
|
const enabled = getSetting('whatsapp_auto_billing_enabled', false);
|
||||||
const waEnabled = getSetting('whatsapp_enabled', false);
|
const waEnabled = getSetting('whatsapp_enabled', false);
|
||||||
const billingEnabled = getSetting('whatsapp_billing_to_customer_enabled', true);
|
const billingEnabled = getSetting('whatsapp_billing_to_customer_enabled', true);
|
||||||
if (!enabled || !waEnabled || !billingEnabled) return { target: 0, sent: 0, failed: 0, skipped: 'disabled' };
|
if (!enabled || !waEnabled || !billingEnabled) return { target: 0, sent: 0, failed: 0, skipped: (!waEnabled || !billingEnabled) ? 'wa_disabled' : 'disabled' };
|
||||||
|
|
||||||
const gatewayType = getSetting('wa_gateway_type', 'gowa');
|
const gatewayType = getSetting('wa_gateway_type', 'gowa');
|
||||||
const waSvc = require('./whatsappService');
|
const waSvc = require('./whatsappService');
|
||||||
@@ -70,13 +71,13 @@ async function runAutoBillingReminder() {
|
|||||||
const token = getSetting('meta_access_token', '');
|
const token = getSetting('meta_access_token', '');
|
||||||
if (!phoneId || !token) {
|
if (!phoneId || !token) {
|
||||||
logger.warn('[CRON] Kredensial Meta API belum diisi, pengingat tagihan otomatis dilewati.');
|
logger.warn('[CRON] Kredensial Meta API belum diisi, pengingat tagihan otomatis dilewati.');
|
||||||
return;
|
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
|
||||||
}
|
}
|
||||||
} else if (gatewayType === 'fonnte') {
|
} else if (gatewayType === 'fonnte') {
|
||||||
const token = getSetting('fonnte_token', '');
|
const token = getSetting('fonnte_token', '');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
logger.warn('[CRON] Token Fonnte belum diisi, pengingat tagihan otomatis dilewati.');
|
logger.warn('[CRON] Token Fonnte belum diisi, pengingat tagihan otomatis dilewati.');
|
||||||
return;
|
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Gateway gowa/baileys/lainnya → cek koneksi gateway aktif (gateway-aware)
|
// Gateway gowa/baileys/lainnya → cek koneksi gateway aktif (gateway-aware)
|
||||||
@@ -84,7 +85,7 @@ async function runAutoBillingReminder() {
|
|||||||
const connected = await waNotify.isConnected().catch(() => false);
|
const connected = await waNotify.isConnected().catch(() => false);
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
logger.warn('[CRON] WhatsApp gateway belum terhubung, pengingat tagihan otomatis dilewati.');
|
logger.warn('[CRON] WhatsApp gateway belum terhubung, pengingat tagihan otomatis dilewati.');
|
||||||
return;
|
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,14 +214,13 @@ async function runAutoBillingReminder() {
|
|||||||
|
|
||||||
// Jika amt tidak ada atau amt tidak cocok dengan akumulasi total tagihan + kode, buat/perbarui nominal unik
|
// Jika amt tidak ada atau amt tidak cocok dengan akumulasi total tagihan + kode, buat/perbarui nominal unik
|
||||||
if ((!code || !amt || (amt - code !== baseAmount)) && invId > 0 && baseAmount > 0) {
|
if ((!code || !amt || (amt - code !== baseAmount)) && invId > 0 && baseAmount > 0) {
|
||||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
|
||||||
const custId = Number(c?.id || inv?.customer_id || 0);
|
const custId = Number(c?.id || inv?.customer_id || 0);
|
||||||
let tryCode = 0;
|
let tryCode = 0;
|
||||||
let tryAmt = 0;
|
let tryAmt = 0;
|
||||||
|
|
||||||
if (code > 0) {
|
if (code > 0) {
|
||||||
const pAmt = baseAmount + code;
|
const pAmt = baseAmount + code;
|
||||||
if (!exists.get('unpaid', pAmt, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||||
tryCode = code;
|
tryCode = code;
|
||||||
tryAmt = pAmt;
|
tryAmt = pAmt;
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,7 @@ async function runAutoBillingReminder() {
|
|||||||
if (!tryAmt && custId > 0) {
|
if (!tryAmt && custId > 0) {
|
||||||
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
|
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
|
||||||
const pAmt = baseAmount + prefCode;
|
const pAmt = baseAmount + prefCode;
|
||||||
if (!exists.get('unpaid', pAmt, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||||
tryCode = prefCode;
|
tryCode = prefCode;
|
||||||
tryAmt = pAmt;
|
tryAmt = pAmt;
|
||||||
}
|
}
|
||||||
@@ -238,7 +238,7 @@ async function runAutoBillingReminder() {
|
|||||||
if (!tryAmt) {
|
if (!tryAmt) {
|
||||||
for (let randCode = 1; randCode <= 499; randCode++) {
|
for (let randCode = 1; randCode <= 499; randCode++) {
|
||||||
const pAmt = baseAmount + randCode;
|
const pAmt = baseAmount + randCode;
|
||||||
if (!exists.get('unpaid', pAmt, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||||
tryCode = randCode;
|
tryCode = randCode;
|
||||||
tryAmt = pAmt;
|
tryAmt = pAmt;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -7,6 +7,29 @@ const db = require('../config/database');
|
|||||||
const qrisUtil = require('../utils/qrisUtil');
|
const qrisUtil = require('../utils/qrisUtil');
|
||||||
const { getSettings } = require('../config/settingsManager');
|
const { getSettings } = require('../config/settingsManager');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cek apakah nominal unik sudah dipakai order lain yang masih menunggu bayar.
|
||||||
|
* WAJIB mencakup invoice, voucher, dan donasi: webhook mencocokkan ketiganya
|
||||||
|
* sekaligus, jadi nominal yang dipakai dua order berbeda akan dianggap ambigu
|
||||||
|
* dan pembayarannya tidak diproses.
|
||||||
|
*/
|
||||||
|
function isQrisAmountTaken(amount, opts = {}) {
|
||||||
|
const amt = Number(amount || 0);
|
||||||
|
if (!Number.isFinite(amt) || amt <= 0) return false;
|
||||||
|
const exInv = Number(opts.excludeInvoiceId || 0);
|
||||||
|
const exVoucher = Number(opts.excludeVoucherOrderId || 0);
|
||||||
|
const exDonation = Number(opts.excludeDonationOrderId || 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;
|
||||||
|
const voc = db.prepare('SELECT id FROM public_voucher_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, exVoucher);
|
||||||
|
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;
|
||||||
|
} catch (e) { /* tabel opsional belum ada */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureInvoiceQris(inv) {
|
function ensureInvoiceQris(inv) {
|
||||||
const settings = getSettings() || {};
|
const settings = getSettings() || {};
|
||||||
const invId = Number((inv && inv.id) || 0);
|
const invId = Number((inv && inv.id) || 0);
|
||||||
@@ -17,11 +40,10 @@ function ensureInvoiceQris(inv) {
|
|||||||
let amountUnique = Number(inv.qris_amount_unique || 0);
|
let amountUnique = Number(inv.qris_amount_unique || 0);
|
||||||
|
|
||||||
if (!(amountUnique > 0 && code > 0 && (amountUnique - code) === base)) {
|
if (!(amountUnique > 0 && code > 0 && (amountUnique - code) === base)) {
|
||||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
|
||||||
let chosen = 0, chosenAmt = 0;
|
let chosen = 0, chosenAmt = 0;
|
||||||
for (let c = 1; c <= 999; c++) {
|
for (let c = 1; c <= 999; c++) {
|
||||||
const a = base + c;
|
const a = base + c;
|
||||||
if (!exists.get('unpaid', a, invId)) { chosen = c; chosenAmt = a; break; }
|
if (!isQrisAmountTaken(a, { excludeInvoiceId: invId })) { chosen = c; chosenAmt = a; break; }
|
||||||
}
|
}
|
||||||
if (chosen) {
|
if (chosen) {
|
||||||
code = chosen;
|
code = chosen;
|
||||||
@@ -43,4 +65,4 @@ function ensureInvoiceQris(inv) {
|
|||||||
return { uniqueCode: code, amount: finalAmount, payload };
|
return { uniqueCode: code, amount: finalAmount, payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ensureInvoiceQris };
|
module.exports = { ensureInvoiceQris, isQrisAmountTaken };
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.5.4
|
0.5.5
|
||||||
Reference in New Issue
Block a user