OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana
Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor, admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS. Kredit & lisensi (ISC): - Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak atas base kode dan repo yang bermanfaat. - Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder. - Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
This commit is contained in:
commit
eaf05f2811
280 files changed
+123975
No files matched your search
@@ -0,0 +1,148 @@
|
||||
'use strict';
|
||||
/**
|
||||
* billingSendService.js — Kirim notifikasi tagihan (manual) dengan opsi:
|
||||
* - target tanggal isolir (isolate_day)
|
||||
* - template (dapat dipilih)
|
||||
* - interval statis atau random (anti-ban)
|
||||
* Status dipantau dari halaman admin.
|
||||
*/
|
||||
const db = require('../config/database');
|
||||
const { getSetting } = require('../config/settingsManager');
|
||||
const billingSvc = require('./billingService');
|
||||
|
||||
const TEMPLATE_LABELS = {
|
||||
whatsapp_auto_billing_message: 'Pengingat Tagihan',
|
||||
whatsapp_billing_qris_message: 'Tagihan QRIS (dengan gambar QR)',
|
||||
whatsapp_arrears_warning_message: 'Peringatan Tunggakan',
|
||||
whatsapp_isolir_message: 'Pemberitahuan Isolir',
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
let STATUS = {
|
||||
running: false, total: 0, sent: 0, failed: 0, skipped: 0,
|
||||
startedAt: null, finishedAt: null, last: '', label: '', stopped: false,
|
||||
};
|
||||
|
||||
function getStatus() { return STATUS; }
|
||||
|
||||
function stop() { STATUS.stopped = true; return STATUS; }
|
||||
|
||||
function portalLink() {
|
||||
let b = String(getSetting('public_base_url', '') || '').trim();
|
||||
if (!b) { const h = String(getSetting('server_host', '') || '').trim(); if (h) b = (/^https?:\/\//i.test(h) ? h : 'http://' + h); }
|
||||
return (b ? b.replace(/\/+$/, '') : '') + '/customer';
|
||||
}
|
||||
|
||||
function spintax(t) {
|
||||
return String(t || '').replace(/\{([^{}|]+(?:\|[^{}|]+)+)\}/g, function (m, c) {
|
||||
const a = c.split('|'); return a[Math.floor(Math.random() * a.length)].trim();
|
||||
});
|
||||
}
|
||||
|
||||
function buildMessage(tplRaw, c, inv) {
|
||||
const amount = Number(inv.amount || 0);
|
||||
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(/{{rincian}}/gi, period)
|
||||
.replace(/{{periode}}/gi, period)
|
||||
.replace(/{{bulan_nunggak}}/gi, String(c.unpaid_streak || 0))
|
||||
.replace(/{{batas}}/gi, String(getSetting('auto_deactivate_months', 3) || 3))
|
||||
.replace(/{{link}}/gi, portalLink())
|
||||
.replace(/{{link_portal}}/gi, portalLink())
|
||||
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
|
||||
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
|
||||
.replace(/{{qris_qr}}/gi, 'QRIS terlampir'));
|
||||
}
|
||||
|
||||
function buildTargets(isolateDay) {
|
||||
const targets = [];
|
||||
let q = "SELECT * FROM customers WHERE package_id IS NOT NULL AND status IN ('active','ditangguhkan','suspended')";
|
||||
const params = [];
|
||||
if (Number(isolateDay) > 0) { q += ' AND COALESCE(isolate_day,0)=?'; params.push(Number(isolateDay)); }
|
||||
q += ' ORDER BY name';
|
||||
const custs = db.prepare(q).all(...params);
|
||||
for (const c of custs) {
|
||||
let invs = [];
|
||||
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] });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
async function start(opts = {}) {
|
||||
if (STATUS.running) throw new Error('Pengiriman sedang berjalan. Tunggu selesai atau hentikan dulu.');
|
||||
const isolateDay = parseInt(opts.isolateDay, 10) || 0;
|
||||
const templateKey = String(opts.templateKey || 'whatsapp_auto_billing_message');
|
||||
const intervalMode = String(opts.intervalMode || 'static').toLowerCase();
|
||||
const intervalValue = Math.max(3, parseInt(opts.intervalValue, 10) || 15);
|
||||
const intervalMin = Math.max(3, parseInt(opts.intervalMin, 10) || 15);
|
||||
const intervalMax = Math.max(intervalMin, parseInt(opts.intervalMax, 10) || 40);
|
||||
const isQris = /qris/i.test(templateKey) || templateKey === 'whatsapp_billing_qris_message';
|
||||
|
||||
const def = 'Yth. {{nama}},\n\nBerikut tagihan internet Anda:\n📦 {{paket}}\n💰 Rp {{tagihan}}\n📅 {{rincian}}\n\nBayar: {{link}}';
|
||||
const tplRaw = db.getAppSetting(templateKey, def) || def;
|
||||
|
||||
const targets = buildTargets(isolateDay);
|
||||
if (!targets.length) {
|
||||
try { db.prepare("INSERT INTO automation_runs (type, template_key, isolate_day, mode, interval_label, target, sent, failed, status, note) VALUES ('manual',?,?,?,'-',0,0,0,'empty','Tidak ada target')").run(templateKey, isolateDay, intervalMode); } catch (e) {}
|
||||
return { started: false, total: 0, message: 'Tidak ada target (pelanggan belum bayar) untuk tanggal isolir tsb.' };
|
||||
}
|
||||
|
||||
STATUS = {
|
||||
running: true, total: targets.length, sent: 0, failed: 0, skipped: 0,
|
||||
startedAt: new Date().toISOString(), finishedAt: null, last: '',
|
||||
label: (isolateDay ? ('Isolir tgl ' + isolateDay) : 'Semua') + ' • ' + (TEMPLATE_LABELS[templateKey] || templateKey) + ' • ' + (intervalMode === 'random' ? ('acak ' + intervalMin + '-' + intervalMax + 's') : ('statis ' + intervalValue + 's')),
|
||||
stopped: false,
|
||||
};
|
||||
|
||||
let runId = 0;
|
||||
try { runId = Number(db.prepare("INSERT INTO automation_runs (type, template_key, isolate_day, mode, interval_label, target, sent, failed, status) VALUES ('manual',?,?,?,?,?,0,0,'running')").run(templateKey, isolateDay, intervalMode, STATUS.label, targets.length).lastInsertRowid) || 0; } catch (e) {}
|
||||
|
||||
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) {} }
|
||||
|
||||
(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);
|
||||
}
|
||||
} else {
|
||||
await waNotify.sendText(c.phone, caption);
|
||||
}
|
||||
STATUS.sent++; STATUS.last = c.name;
|
||||
} catch (e) { STATUS.failed++; STATUS.last = c.name + ' (gagal)'; }
|
||||
if (i < targets.length - 1 && !STATUS.stopped) {
|
||||
const d = intervalMode === 'random'
|
||||
? (intervalMin + Math.floor(Math.random() * (intervalMax - intervalMin + 1)))
|
||||
: intervalValue;
|
||||
await sleep(d * 1000);
|
||||
}
|
||||
}
|
||||
STATUS.running = false;
|
||||
STATUS.finishedAt = new Date().toISOString();
|
||||
if (runId) { try { db.prepare("UPDATE automation_runs SET sent=?, failed=?, status=?, finished_at=(NOW_LOCAL()) WHERE id=?").run(STATUS.sent, STATUS.failed, STATUS.stopped ? 'stopped' : 'done', runId); } catch (e) {} }
|
||||
})();
|
||||
|
||||
return { started: true, total: targets.length, label: STATUS.label };
|
||||
}
|
||||
|
||||
module.exports = { start, getStatus, stop, TEMPLATE_LABELS, buildTargets };
|
||||
Reference in new issue
Block a user