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,432 @@
|
||||
const { logger } = require('../config/logger');
|
||||
const { getSetting } = require('../config/settingsManager');
|
||||
const db = require('../config/database');
|
||||
const metaWAService = require('./metaWhatsappService');
|
||||
const fonnteWAService = require('./fonnteWhatsappService');
|
||||
const gowaWAService = require('./gowaWhatsappService');
|
||||
|
||||
/* ═══ Helper periode & tunggakan (port upstream 105fcab) ═══ */
|
||||
const INDONESIAN_MONTH_NAMES = [
|
||||
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
||||
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
|
||||
];
|
||||
|
||||
function getIndonesianMonthName(monthNumber) {
|
||||
const m = parseInt(monthNumber, 10);
|
||||
if (m >= 1 && m <= 12) {
|
||||
return INDONESIAN_MONTH_NAMES[m - 1];
|
||||
}
|
||||
return String(monthNumber || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format periode bulan dan tahun ke dalam bahasa Indonesia, misal: "Oktober 2026"
|
||||
* @param {string|number} month - Bulan (angka 1-12 atau string nama bulan atau "10/2026")
|
||||
* @param {string|number} [year] - Tahun (misal 2026)
|
||||
* @returns {string} Contoh: "Oktober 2026"
|
||||
*/
|
||||
function formatIndonesianPeriod(month, year) {
|
||||
if (!month && !year) return '-';
|
||||
let m = String(month || '').trim();
|
||||
let y = String(year || '').trim();
|
||||
|
||||
if (!y && m.includes('/')) {
|
||||
const parts = m.split('/');
|
||||
m = parts[0].trim();
|
||||
y = parts[1].trim();
|
||||
} else if (!y && m.includes('-')) {
|
||||
const parts = m.split('-');
|
||||
if (parts[0].length === 4) {
|
||||
y = parts[0].trim();
|
||||
m = parts[1].trim();
|
||||
} else {
|
||||
m = parts[0].trim();
|
||||
y = parts[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Jika terdapat koma (banyak bulan)
|
||||
if (m.includes(',')) {
|
||||
const monthItems = m.split(',').map(x => x.trim()).filter(Boolean);
|
||||
const formattedMonths = monthItems.map(x => getIndonesianMonthName(x));
|
||||
if (formattedMonths.length === 2) {
|
||||
return `${formattedMonths[0]} & ${formattedMonths[1]}${y ? ' ' + y : ''}`;
|
||||
} else if (formattedMonths.length > 2) {
|
||||
const last = formattedMonths.pop();
|
||||
return `${formattedMonths.join(', ')} & ${last}${y ? ' ' + y : ''}`;
|
||||
}
|
||||
return `${formattedMonths.join(', ')}${y ? ' ' + y : ''}`;
|
||||
}
|
||||
|
||||
const mName = getIndonesianMonthName(m);
|
||||
return y ? `${mName} ${y}` : mName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format daftar tagihan belum lunas (unpaid invoices) menjadi ringkasan yang jelas:
|
||||
* - count: jumlah tagihan belum lunas
|
||||
* - totalAmount: akumulasi nominal rupiah
|
||||
* - totalAmountStr: format string rupiah (misal "220.000")
|
||||
* - periodText: nama bulan dalam huruf (misal "September & Oktober 2026 (2 Bulan)")
|
||||
* - breakdownText: rincian baris per bulan
|
||||
* - isMultiple: boolean apakah menunggak > 1 bulan
|
||||
*/
|
||||
function formatUnpaidInvoicesSummary(unpaidInvoices) {
|
||||
if (!unpaidInvoices || !Array.isArray(unpaidInvoices) || unpaidInvoices.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
totalAmount: 0,
|
||||
totalAmountStr: '0',
|
||||
periodText: '-',
|
||||
breakdownText: '',
|
||||
isMultiple: false
|
||||
};
|
||||
}
|
||||
|
||||
const formatter = new Intl.NumberFormat('id-ID');
|
||||
const count = unpaidInvoices.length;
|
||||
const totalAmount = unpaidInvoices.reduce((sum, inv) => sum + (Number(inv.amount) || 0), 0);
|
||||
const totalAmountStr = formatter.format(totalAmount);
|
||||
|
||||
// Urutkan invoice berdasarkan period_year asc, period_month asc, id asc
|
||||
const sorted = [...unpaidInvoices].sort((a, b) => {
|
||||
const yA = Number(a.period_year) || 0;
|
||||
const yB = Number(b.period_year) || 0;
|
||||
if (yA !== yB) return yA - yB;
|
||||
const mA = Number(a.period_month) || 0;
|
||||
const mB = Number(b.period_month) || 0;
|
||||
if (mA !== mB) return mA - mB;
|
||||
return (Number(a.id) || 0) - (Number(b.id) || 0);
|
||||
});
|
||||
|
||||
const periodStrings = sorted.map(inv => formatIndonesianPeriod(inv.period_month, inv.period_year));
|
||||
|
||||
let periodText = '';
|
||||
if (periodStrings.length === 1) {
|
||||
periodText = periodStrings[0];
|
||||
} else if (periodStrings.length === 2) {
|
||||
periodText = `${periodStrings[0]} & ${periodStrings[1]}`;
|
||||
} else {
|
||||
const last = periodStrings[periodStrings.length - 1];
|
||||
periodText = `${periodStrings.slice(0, -1).join(', ')} & ${last}`;
|
||||
}
|
||||
|
||||
const breakdownLines = sorted.map(inv => {
|
||||
const pStr = formatIndonesianPeriod(inv.period_month, inv.period_year);
|
||||
const amtStr = formatter.format(Number(inv.amount) || 0);
|
||||
return `• *${pStr}:* Rp ${amtStr}${inv.id ? ` (Inv #${inv.id})` : ''}`;
|
||||
});
|
||||
|
||||
return {
|
||||
count,
|
||||
totalAmount,
|
||||
totalAmountStr,
|
||||
periodText: count > 1 ? `${periodText} (${count} Bulan)` : periodText,
|
||||
breakdownText: breakdownLines.join('\n'),
|
||||
isMultiple: count > 1
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Unified WhatsApp Gateway Service
|
||||
* Menangani routing pengiriman pesan baik via Baileys (Unofficial Socket), Fonnte API, maupun Meta Cloud API (Official Meta).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Kirim pesan WhatsApp universal
|
||||
* @param {string} toPhone Nomor HP tujuan
|
||||
* @param {string} messageText Teks pesan
|
||||
* @param {object} options Opsi tambahan: { templateName, parameters, langCode, url, filename }
|
||||
*/
|
||||
async function sendWhatsAppMessage(toPhone, messageText, options = {}) {
|
||||
const gatewayType = getSetting('wa_gateway_type', 'baileys'); // 'baileys', 'fonnte', or 'meta'
|
||||
|
||||
if (gatewayType === 'fonnte') {
|
||||
// Mode FONNTE API GATEWAY (Cloud / Self-Hosted)
|
||||
const res = await fonnteWAService.sendFonnteMessage(toPhone, messageText, options);
|
||||
return res && res.success;
|
||||
} else if (gatewayType === 'meta') {
|
||||
// Mode META API RESMI
|
||||
if (options.templateName) {
|
||||
// Kirim via Meta Template Message
|
||||
return await metaWAService.sendMetaTemplateMessage(
|
||||
toPhone,
|
||||
options.templateName,
|
||||
options.langCode || 'id',
|
||||
options.parameters || []
|
||||
);
|
||||
} else {
|
||||
// Kirim via Meta Direct Text Message
|
||||
return await metaWAService.sendMetaTextMessage(toPhone, messageText);
|
||||
}
|
||||
} else if (gatewayType === 'gowa') {
|
||||
// Mode GOWA (go-whatsapp-web-multidevice)
|
||||
const res = await gowaWAService.sendGowaMessage(toPhone, messageText, options);
|
||||
try {
|
||||
const phone = metaWAService.normalizePhone(toPhone);
|
||||
let custName = 'Pelanggan'; let custId = null;
|
||||
const cust = db.prepare('SELECT id, name FROM customers WHERE phone LIKE ? OR phone LIKE ?').get(`%${phone.slice(-8)}%`, `%${phone}%`);
|
||||
if (cust) { custName = cust.name; custId = cust.id; }
|
||||
db.prepare(`INSERT INTO wa_chat_messages (direction, gateway, sender_phone, recipient_phone, customer_id, customer_name, message_text, status) VALUES ('outbound','gowa','gowa_bot',?,?,?,?,'sent')`).run(phone, custId, custName, messageText);
|
||||
} catch (e) {}
|
||||
return res && res.success;
|
||||
} else {
|
||||
// Mode BAILEYS WEB (Default Socket)
|
||||
const { sendWA, whatsappStatus } = await import('./whatsappBot.mjs');
|
||||
|
||||
if (!whatsappStatus || whatsappStatus.connection !== 'open') {
|
||||
throw new Error('Bot WhatsApp (Baileys) belum terhubung / offline. Silakan scan QR Code di menu /admin/whatsapp.');
|
||||
}
|
||||
|
||||
const ok = await sendWA(toPhone, messageText, options);
|
||||
|
||||
if (!ok) {
|
||||
throw new Error('Gagal mengirim pesan via Baileys. Pastikan nomor HP tujuan terdaftar di WhatsApp.');
|
||||
}
|
||||
|
||||
// Logging ke wa_chat_messages untuk Inbox / Live Chat
|
||||
try {
|
||||
const phone = metaWAService.normalizePhone(toPhone);
|
||||
let custName = 'Pelanggan';
|
||||
let custId = null;
|
||||
const cust = db.prepare('SELECT id, name FROM customers WHERE phone LIKE ? OR phone LIKE ?').get(`%${phone.slice(-8)}%`, `%${phone}%`);
|
||||
if (cust) {
|
||||
custName = cust.name;
|
||||
custId = cust.id;
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO wa_chat_messages
|
||||
(direction, gateway, sender_phone, recipient_phone, customer_id, customer_name, message_text, status)
|
||||
VALUES ('outbound', 'baileys', 'baileys_bot', ?, ?, ?, ?, 'sent')
|
||||
`).run(phone, custId, custName, messageText);
|
||||
} catch (e) {}
|
||||
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambil riwayat chat / percakapan dengan nomor tertentu untuk Live Chat
|
||||
*/
|
||||
function getChatHistory(phone, limit = 50) {
|
||||
const normalized = metaWAService.normalizePhone(phone);
|
||||
if (!normalized) return [];
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM wa_chat_messages
|
||||
WHERE sender_phone LIKE ? OR recipient_phone LIKE ? OR sender_phone LIKE ? OR recipient_phone LIKE ?
|
||||
ORDER BY id ASC
|
||||
LIMIT ?
|
||||
`).all(`%${normalized.slice(-8)}%`, `%${normalized.slice(-8)}%`, `%${normalized}%`, `%${normalized}%`, Number(limit) || 50);
|
||||
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambil daftar percakapan terbaru (Inbox Live Chat)
|
||||
*/
|
||||
function getRecentConversations(limit = 30) {
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
m.id,
|
||||
m.direction,
|
||||
m.gateway,
|
||||
CASE WHEN m.direction = 'inbound' THEN m.sender_phone ELSE m.recipient_phone END as phone,
|
||||
m.customer_id,
|
||||
m.customer_name,
|
||||
m.message_text,
|
||||
m.status,
|
||||
m.created_at
|
||||
FROM wa_chat_messages m
|
||||
INNER JOIN (
|
||||
SELECT MAX(id) as max_id
|
||||
FROM wa_chat_messages
|
||||
GROUP BY CASE WHEN direction = 'inbound' THEN sender_phone ELSE recipient_phone END
|
||||
) latest ON m.id = latest.max_id
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ?
|
||||
`).all(Number(limit) || 30);
|
||||
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
const DEFAULT_PAYMENT_SUCCESS_TEMPLATE =
|
||||
`🧾 *BUKTI PEMBAYARAN RESMI (LUNAS)*
|
||||
🏢 *{{company}}*
|
||||
────────────────────────────
|
||||
Yth. Pelanggan *{{nama}}*,
|
||||
|
||||
Terima kasih, pembayaran tagihan internet Anda telah kami terima dan diverifikasi.
|
||||
|
||||
📋 *Rincian Pembayaran:*
|
||||
• *No. Invoice:* #INV-{{no_invoice}}
|
||||
• *ID Pelanggan:* {{id_pelanggan}}
|
||||
• *Paket Layanan:* {{paket}}
|
||||
• *Periode:* {{periode}}
|
||||
• *Waktu Bayar:* {{waktu}}
|
||||
• *Metode Bayar:* {{metode}}
|
||||
• *Diterima Oleh:* {{penerima}}
|
||||
• *Total Bayar:* *Rp {{total}}*
|
||||
• *Status:* *LUNAS ✅*
|
||||
|
||||
🌐 *Status Layanan:*
|
||||
Layanan internet Anda saat ini dalam status *AKTIF* dan dapat digunakan dengan nyaman.
|
||||
|
||||
📄 *Unduh Invoice (PDF):*
|
||||
{{link_pdf}}
|
||||
|
||||
────────────────────────────
|
||||
🔗 *Cek Tagihan / Riwayat:*
|
||||
{{link_portal}}
|
||||
|
||||
📞 *Bantuan & Layanan Pelanggan:*
|
||||
WhatsApp: {{cs_phone}}
|
||||
|
||||
_Simpan pesan ini sebagai bukti pembayaran yang sah dari {{company}}._`;
|
||||
|
||||
function getIndonesianMonthName(monthNumber) {
|
||||
const months = [
|
||||
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
||||
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
|
||||
];
|
||||
const idx = Number(monthNumber) - 1;
|
||||
return months[idx] || String(monthNumber || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Payment Success WhatsApp Message
|
||||
*/
|
||||
function formatPaymentSuccessMessage({
|
||||
customerName = '',
|
||||
invoiceId = '',
|
||||
customerUsername = '',
|
||||
customerCode = '',
|
||||
packageName = '',
|
||||
periodMonth = '',
|
||||
periodYear = '',
|
||||
amount = 0,
|
||||
paymentMethod = '',
|
||||
paidAt = null,
|
||||
companyName = '',
|
||||
companyPhone = '',
|
||||
portalUrl = '',
|
||||
linkPdf = '',
|
||||
receiver = '',
|
||||
csPhone = '',
|
||||
customTemplate = '',
|
||||
remainingUnpaidInvoices = null,
|
||||
customerId = null
|
||||
}) {
|
||||
let template = String(customTemplate || DEFAULT_PAYMENT_SUCCESS_TEMPLATE).trim();
|
||||
if (!template) template = DEFAULT_PAYMENT_SUCCESS_TEMPLATE;
|
||||
|
||||
const periodText = formatIndonesianPeriod(periodMonth, periodYear) || '-';
|
||||
|
||||
const tz = getSetting('timezone', 'Asia/Jakarta');
|
||||
let tzSuffix = 'WIB';
|
||||
if (/Makassar|Ujung_Pandang|Bali|Pontianak|Banjarmasin|Manado|Mataram|Kupang/i.test(tz)) {
|
||||
tzSuffix = 'WITA';
|
||||
} else if (/Jayapura|Ambon|Papua|Maluku/i.test(tz)) {
|
||||
tzSuffix = 'WIT';
|
||||
} else if (!/Jakarta|Bangkok|Asia\/Jakarta/i.test(tz)) {
|
||||
tzSuffix = tz;
|
||||
}
|
||||
|
||||
let formattedTime = '';
|
||||
try {
|
||||
const { formatDateLocal } = require('../config/settingsManager');
|
||||
formattedTime = `${formatDateLocal(paidAt || new Date())} ${tzSuffix}`;
|
||||
} catch {
|
||||
const now = new Date();
|
||||
formattedTime = `${now.toLocaleDateString('id-ID')} ${now.toLocaleTimeString('id-ID')} ${tzSuffix}`;
|
||||
}
|
||||
|
||||
const formattedAmount = Number(amount || 0).toLocaleString('id-ID');
|
||||
|
||||
// Sisa tunggakan lain (struk pelunasan lengkap) — port upstream 105fcab
|
||||
let remainingList = remainingUnpaidInvoices;
|
||||
if (remainingList === null && customerId) {
|
||||
try {
|
||||
const billingSvc = require('./billingService.js');
|
||||
const allUnpaid = billingSvc.getUnpaidInvoicesByCustomerId(customerId) || [];
|
||||
remainingList = allUnpaid.filter(inv => String(inv.id) !== String(invoiceId));
|
||||
} catch (e) {}
|
||||
}
|
||||
let tunggakanInfo = '';
|
||||
if (remainingList && Array.isArray(remainingList) && remainingList.length > 0) {
|
||||
const summary = formatUnpaidInvoicesSummary(remainingList);
|
||||
tunggakanInfo = `\n⚠️ *Perhatian Tunggakan Lain:*\nMasih ada *${summary.count} tagihan lain* belum lunas:\n${summary.breakdownText}\n💰 *Total Sisa Tunggakan:* *Rp ${summary.totalAmountStr}*\n_Mohon segera diselesaikan agar layanan tetap lancar._\n`;
|
||||
}
|
||||
|
||||
const _invIdMatch = String(invoiceId || '').match(/\d+/);
|
||||
const pdfLink = (portalUrl && _invIdMatch) ? (String(portalUrl).replace(/\/$/, '') + '/invoice/' + _invIdMatch[0] + '/pdf') : (portalUrl || '-');
|
||||
|
||||
let rendered = template
|
||||
.replace(/{{nama}}/gi, customerName || 'Pelanggan')
|
||||
.replace(/{{no_invoice}}/gi, String(invoiceId || '-'))
|
||||
.replace(/{{invoice_id}}/gi, String(invoiceId || '-'))
|
||||
.replace(/{{username}}/gi, customerUsername || '-')
|
||||
.replace(/{{id_pelanggan}}/gi, customerCode || customerUsername || '-')
|
||||
.replace(/{{penerima}}/gi, receiver || '-')
|
||||
.replace(/{{diterima_oleh}}/gi, receiver || '-')
|
||||
.replace(/{{cs_phone}}/gi, csPhone || companyPhone || '-')
|
||||
.replace(/{{paket}}/gi, packageName || '-')
|
||||
.replace(/{{periode}}/gi, periodText)
|
||||
.replace(/{{total}}/gi, formattedAmount)
|
||||
.replace(/{{metode}}/gi, paymentMethod || 'Admin / Kasir')
|
||||
.replace(/{{waktu}}/gi, formattedTime)
|
||||
.replace(/{{tanggal}}/gi, formattedTime)
|
||||
.replace(/{{company}}/gi, companyName || 'OPEN-ISP')
|
||||
.replace(/{{company_phone}}/gi, companyPhone || '-')
|
||||
.replace(/{{link_portal}}/gi, portalUrl || '-')
|
||||
.replace(/{{link}}/gi, portalUrl || '-')
|
||||
.replace(/{{link_pdf}}/gi, linkPdf || pdfLink)
|
||||
.replace(/{{pdf}}/gi, linkPdf || pdfLink)
|
||||
.replace(/{{pppoe}}/gi, customerUsername || '-')
|
||||
.replace(/{{tunggakan_info}}/gi, tunggakanInfo);
|
||||
|
||||
// Sisipkan info tunggakan lain bila template tidak punya placeholder-nya
|
||||
if (tunggakanInfo && !template.includes('{{tunggakan_info}}')) {
|
||||
if (rendered.includes('───')) {
|
||||
const idx = rendered.lastIndexOf('───');
|
||||
rendered = rendered.slice(0, idx) + `${tunggakanInfo}\n` + rendered.slice(idx);
|
||||
} else {
|
||||
rendered += `\n${tunggakanInfo}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Process spintax {A|B|C} only if pipe is present
|
||||
rendered = rendered.replace(/\{([^{}|]+(?:\|[^{}|]+)+)\}/g, (match, choices) => {
|
||||
const arr = choices.split('|');
|
||||
return arr[Math.floor(Math.random() * arr.length)].trim();
|
||||
});
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
// Jeda "mengetik manusiawi": meniru tempo menulis (proporsional panjang pesan).
|
||||
// Dipakai sebelum kirim pada pengiriman massal (broadcast/cron) agar tidak terlihat bot.
|
||||
function humanTypingDelay(messageText, opts = {}) {
|
||||
const len = String(messageText || '').length;
|
||||
const perChar = Number(opts.perChar) || 30;
|
||||
const min = Number(opts.min) || 700;
|
||||
const max = Number(opts.max) || 3500;
|
||||
const ms = Math.min(Math.max(len * perChar, min), max);
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatUnpaidInvoicesSummary,
|
||||
formatIndonesianPeriod,
|
||||
getIndonesianMonthName,
|
||||
sendWhatsAppMessage,
|
||||
humanTypingDelay,
|
||||
sendWA: sendWhatsAppMessage, // Alias untuk kompatibilitas fungsi lama
|
||||
getChatHistory,
|
||||
getRecentConversations,
|
||||
getIndonesianMonthName,
|
||||
formatPaymentSuccessMessage,
|
||||
DEFAULT_PAYMENT_SUCCESS_TEMPLATE
|
||||
};
|
||||
Reference in new issue
Block a user