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 };