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:
@@ -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 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;
|
||||
return spintax(String(tplRaw || '')
|
||||
.replace(/{{id_pelanggan}}/gi, c.customer_code || ('ID:' + c.id))
|
||||
.replace(/{{nama}}/gi, c.name || 'Pelanggan')
|
||||
.replace(/{{paket}}/gi, inv.package_name || '-')
|
||||
.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(/{{periode}}/gi, period)
|
||||
.replace(/{{bulan_nunggak}}/gi, String(c.unpaid_streak || 0))
|
||||
@@ -57,7 +61,7 @@ function buildMessage(tplRaw, c, inv) {
|
||||
.replace(/{{link_portal}}/gi, portalLink())
|
||||
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
|
||||
.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) {
|
||||
@@ -72,7 +76,8 @@ function buildTargets(isolateDay) {
|
||||
try { invs = billingSvc.getUnpaidInvoicesByCustomerId(c.id) || []; } catch (e) {}
|
||||
if (!invs.length) 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;
|
||||
}
|
||||
@@ -108,23 +113,19 @@ async function start(opts = {}) {
|
||||
|
||||
const waNotify = require('./waNotifyService');
|
||||
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 () => {
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
if (STATUS.stopped) break;
|
||||
const { c, inv } = targets[i];
|
||||
try {
|
||||
const caption = buildMessage(tplRaw, c, inv);
|
||||
if (isQris && QRCode && ensureInvoiceQris) {
|
||||
const qr = ensureInvoiceQris(inv) || {};
|
||||
if (qr.payload) {
|
||||
const url = await QRCode.toDataURL(qr.payload, { errorCorrectionLevel: 'M', margin: 1, width: 520 });
|
||||
const buf = Buffer.from(String(url).split(',')[1], 'base64');
|
||||
await waNotify.sendImage(c.phone, buf, caption);
|
||||
} else {
|
||||
await waNotify.sendText(c.phone, caption);
|
||||
}
|
||||
const qr = (isQris && ensureInvoiceQris) ? (ensureInvoiceQris(inv) || {}) : null;
|
||||
const caption = buildMessage(tplRaw, c, inv, qr);
|
||||
if (isQris && QRCode && qr && qr.payload) {
|
||||
const url = await QRCode.toDataURL(qr.payload, { errorCorrectionLevel: 'M', margin: 1, width: 520 });
|
||||
const buf = Buffer.from(String(url).split(',')[1], 'base64');
|
||||
await waNotify.sendImage(c.phone, buf, caption);
|
||||
} else {
|
||||
await waNotify.sendText(c.phone, caption);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const usageSvc = require('./usageService');
|
||||
const { getSetting } = require('../config/settingsManager');
|
||||
const db = require('../config/database');
|
||||
const qrisUtil = require('../utils/qrisUtil');
|
||||
const qrisUniq = require('./invoiceQrisService');
|
||||
const { formatUnpaidInvoicesSummary } = require('./whatsappService');
|
||||
|
||||
// Helper: Random delay generator untuk smart rate limiting
|
||||
@@ -60,7 +61,7 @@ async function runAutoBillingReminder() {
|
||||
const enabled = getSetting('whatsapp_auto_billing_enabled', false);
|
||||
const waEnabled = getSetting('whatsapp_enabled', false);
|
||||
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 waSvc = require('./whatsappService');
|
||||
@@ -70,13 +71,13 @@ async function runAutoBillingReminder() {
|
||||
const token = getSetting('meta_access_token', '');
|
||||
if (!phoneId || !token) {
|
||||
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') {
|
||||
const token = getSetting('fonnte_token', '');
|
||||
if (!token) {
|
||||
logger.warn('[CRON] Token Fonnte belum diisi, pengingat tagihan otomatis dilewati.');
|
||||
return;
|
||||
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
|
||||
}
|
||||
} else {
|
||||
// Gateway gowa/baileys/lainnya → cek koneksi gateway aktif (gateway-aware)
|
||||
@@ -84,7 +85,7 @@ async function runAutoBillingReminder() {
|
||||
const connected = await waNotify.isConnected().catch(() => false);
|
||||
if (!connected) {
|
||||
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
|
||||
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);
|
||||
let tryCode = 0;
|
||||
let tryAmt = 0;
|
||||
|
||||
if (code > 0) {
|
||||
const pAmt = baseAmount + code;
|
||||
if (!exists.get('unpaid', pAmt, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||
tryCode = code;
|
||||
tryAmt = pAmt;
|
||||
}
|
||||
@@ -229,7 +229,7 @@ async function runAutoBillingReminder() {
|
||||
if (!tryAmt && custId > 0) {
|
||||
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
|
||||
const pAmt = baseAmount + prefCode;
|
||||
if (!exists.get('unpaid', pAmt, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||
tryCode = prefCode;
|
||||
tryAmt = pAmt;
|
||||
}
|
||||
@@ -238,7 +238,7 @@ async function runAutoBillingReminder() {
|
||||
if (!tryAmt) {
|
||||
for (let randCode = 1; randCode <= 499; randCode++) {
|
||||
const pAmt = baseAmount + randCode;
|
||||
if (!exists.get('unpaid', pAmt, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||
tryCode = randCode;
|
||||
tryAmt = pAmt;
|
||||
break;
|
||||
|
||||
@@ -7,6 +7,29 @@ const db = require('../config/database');
|
||||
const qrisUtil = require('../utils/qrisUtil');
|
||||
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) {
|
||||
const settings = getSettings() || {};
|
||||
const invId = Number((inv && inv.id) || 0);
|
||||
@@ -17,11 +40,10 @@ function ensureInvoiceQris(inv) {
|
||||
let amountUnique = Number(inv.qris_amount_unique || 0);
|
||||
|
||||
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;
|
||||
for (let c = 1; c <= 999; 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) {
|
||||
code = chosen;
|
||||
@@ -43,4 +65,4 @@ function ensureInvoiceQris(inv) {
|
||||
return { uniqueCode: code, amount: finalAmount, payload };
|
||||
}
|
||||
|
||||
module.exports = { ensureInvoiceQris };
|
||||
module.exports = { ensureInvoiceQris, isQrisAmountTaken };
|
||||
|
||||
Reference in New Issue
Block a user