Files
open-isp/app-customer.js
T

2245 lines
90 KiB
JavaScript

const express = require('express');
const compression = require('compression');
const path = require('path');
const fs = require('fs');
const dns = require('dns');
require('dotenv').config({ path: path.join(__dirname, '.env') });
const crypto = require('crypto');
const multer = require('multer');
const QRCode = require('qrcode');
const _jimpMod = require('jimp');
const Jimp = _jimpMod.Jimp || _jimpMod;
const qrisUtil = require('./utils/qrisUtil');
const { logger } = require('./config/logger');
// Semua console.* (server) diarahkan ke logger agar log seragam & tersimpan di logs/.
// Dipasang SETELAH logger dibuat: winston sudah mengikat console asli -> tanpa rekursi.
const _logFmt = (x) => {
if (x instanceof Error) return x.stack || x.message;
if (x && typeof x === 'object') { try { return JSON.stringify(x); } catch (e) { return String(x); } }
return typeof x === 'undefined' ? 'undefined' : String(x);
};
for (const [m, lvl] of [['log', 'info'], ['info', 'info'], ['warn', 'warn'], ['error', 'error'], ['debug', 'debug']]) {
console[m] = (...args) => logger[lvl](args.map(_logFmt).join(' '));
}
const db = require('./config/database');
const customerSvc = require('./services/customerService');
const noticeSvc = require('./services/serviceNoticeService');
const billingSvc = require('./services/billingService');
const whatsappService = require('./services/whatsappService');
const mikrotikService = require('./services/mikrotikService');
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
const { scheduleAutoBackup } = require('./services/backupService');
// Prefer IPv4 to avoid AggregateError (IPv6 timeouts) on some servers
if (dns.setDefaultResultOrder) {
dns.setDefaultResultOrder('ipv4first');
}
// Handle unhandled promise rejections to prevent silent crashes
process.on('unhandledRejection', (reason, promise) => {
const errorMsg = reason instanceof Error ? reason.stack : JSON.stringify(reason);
logger.error(`Unhandled Rejection: ${errorMsg}`);
});
// Handle uncaught exceptions to prevent server crashes from external service failures
// (e.g. ros-client throws uncaught errors when MikroTik router is unreachable)
process.on('uncaughtException', (err) => {
const errorMsg = err instanceof Error ? err.stack : String(err);
logger.error(`uncaughtException: ${errorMsg}`);
// Don't exit process — keep server running despite transient connection errors
});
// Settings Management
const session = require('express-session');
const createSqliteSessionStore = require('./config/sqliteSessionStore');
const { getSetting, getSettingsWithCache, ensureDefaultSettings, getNowLocal, formatDateLocal, formatTimeLocal, parseDateInTimezone } = require('./config/settingsManager');
const { SUPPORTED_LANGS, FALLBACK_LANG, normalizeLang, t } = require('./config/i18n');
// Pastikan semua default settings ada (untuk migrasi/update dari GitHub)
if (typeof ensureDefaultSettings === 'function') {
ensureDefaultSettings();
}
// Inisialisasi aplikasi Express
const app = express();
// Kompresi gzip (performa: respons HTML/CSS/JS lebih kecil)
app.use(compression());
const isProduction = process.env.NODE_ENV === 'production';
const cookieSecure = getSetting('cookie_secure', isProduction);
const trustProxySetting = getSetting('trust_proxy', true);
app.set('trust proxy', trustProxySetting ? 1 : false);
// Middleware dasar
app.use(express.json({
limit: '1mb',
verify: (req, res, buf) => {
req.rawBody = buf?.toString('utf8') || '';
}
}));
app.use(express.urlencoded({
extended: true,
limit: '1mb',
verify: (req, res, buf) => {
req.rawBody = buf?.toString('utf8') || '';
}
}));
app.use(express.text({
type: (req) => {
const contentType = req.headers['content-type'] || '';
if (contentType.includes('multipart/form-data')) return false;
if (contentType.includes('application/x-www-form-urlencoded')) return false;
if (contentType.includes('application/json')) return false;
if (contentType.includes('application/octet-stream')) return false; // speed test (streaming upload)
return true;
},
limit: '1mb',
verify: (req, res, buf) => {
req.rawBody = buf?.toString('utf8') || '';
}
}));
app.use(session({
store: createSqliteSessionStore(db),
secret: getSetting('session_secret', 'rahasia-portal-pelanggan-default-ganti-ini'),
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
secure: Boolean(cookieSecure),
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/'
},
name: 'customer.sid'
}));
// Middleware Proteksi CSRF berbasis Referer/Origin (Aman untuk production tanpa merubah EJS)
app.use((req, res, next) => {
const method = req.method;
if (['POST', 'PUT', 'DELETE'].includes(method)) {
const origin = req.headers.origin;
const referer = req.headers.referer;
const host = req.headers.host;
// Kecualikan webhook eksternal, ACS server TR-069, atau payment gateway callback
const isWebhook = req.path.startsWith('/api/webhook') || req.path.startsWith('/webhook') || req.path === '/customer/payment/callback';
const isAcs = req.path.startsWith('/acs');
if (isWebhook || isAcs) {
return next();
}
try {
if (origin) {
const originHost = new URL(origin).host;
if (originHost !== host) {
logger.warn(`[CSRF] Blocked request from unauthorized origin: ${origin} (host: ${host})`);
return res.status(403).json({ error: 'Forbidden - Invalid Origin (CSRF Protection)' });
}
} else if (referer) {
const refererHost = new URL(referer).host;
if (refererHost !== host) {
logger.warn(`[CSRF] Blocked request from unauthorized referer: ${referer} (host: ${host})`);
return res.status(403).json({ error: 'Forbidden - Invalid Referer (CSRF Protection)' });
}
}
} catch (e) {
logger.error(`[CSRF] Parsing referer/origin failed: ${e.message}`);
return res.status(403).json({ error: 'Forbidden - Invalid Referer/Origin Format' });
}
}
next();
});
// i18n middleware (aman: hanya teks UI, tidak mengubah logic fitur)
app.use((req, res, next) => {
if (req.query && typeof req.query.lang === 'string') {
const requested = normalizeLang(req.query.lang);
req.session.lang = requested;
}
const saved = req.session?.lang || getSetting('default_lang', FALLBACK_LANG);
const lang = normalizeLang(saved);
const tz = getSetting('timezone', 'Asia/Jakarta');
res.locals.lang = lang;
res.locals.availableLangs = Array.from(SUPPORTED_LANGS);
res.locals.t = (key, fallback = '') => t(lang, key, fallback);
res.locals.tz = tz;
res.locals.brandName = getSetting('company_header', 'OPEN-ISP');
res.locals.brandLogo = (getSetting('logo_portal', '') || getSetting('company_logo', '') || '/img/logo.png') + (getSetting('logo_version', '') ? ('?v=' + getSetting('logo_version', '')) : '');
res.locals.formatDateLocal = formatDateLocal;
res.locals.formatTimeLocal = formatTimeLocal;
res.locals.parseDateInTimezone = parseDateInTimezone;
res.locals.getNowLocal = getNowLocal;
res.locals.serviceNotice = noticeSvc.getServiceNotice();
res.locals.uptimeUrl = getSetting('uptime_url', '');
res.locals.heroVer = getSetting('landing_hero_version', '');
res.locals.projectUrl = String(getSetting('project_url', 'https://github.com/oxpoe/open-isp') || '').replace(/\/+$/, '');
res.locals.appVersion = String(getSetting('app_version', '0.5.1') || '0.5.1');
res.locals.appVersionName = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime');
res.locals.pwaVersion = String(getSetting('pwa_version', '2.0') || '2.0');
next();
});
// ── Page loader (animasi transisi halaman) — injeksi non-invasif ke semua HTML ──
app.use((req, res, next) => {
const _send = res.send.bind(res);
res.send = function (body) {
try {
if (typeof body === 'string' && body.indexOf('<html') !== -1 && body.indexOf('</head>') !== -1 && body.indexOf('/css/page-loader.css') === -1) {
body = body.replace('</head>', ' <link rel="stylesheet" href="/css/page-loader.css?v=1">\n <script src="/js/page-loader.js" defer></script>\n</head>');
}
} catch (e) {}
return _send(body);
};
next();
});
// Hidden staff portal (Blackhole) — pintu akses login staf dari portal pelanggan
app.get('/blackhole', (req, res) => {
res.set('X-Robots-Tag', 'noindex, nofollow');
res.render('blackhole', { title: 'Blackhole', company: getSetting('company_header', 'OPEN-ISP'), settings: getSettingsWithCache() });
});
app.get('/lang/:lang', (req, res) => {
const targetLang = normalizeLang(req.params.lang);
req.session.lang = targetLang;
// Aman: saat Service Worker aktif, referer bisa berupa /sw.js — jangan redirect mentah.
const host = `${req.protocol}://${req.get('host')}`;
let target = '/';
try {
const ref = req.get('referer') || '';
if (ref) {
const u = new URL(ref, host);
if (u.origin === host && !/\/sw\.js$/i.test(u.pathname)) target = u.pathname + u.search;
}
} catch (e) {}
return res.redirect(target);
});
// Konstanta
const VERSION = '2.0.0';
const insertWebhookPaymentNotif = db.prepare(`
INSERT INTO webhook_payment_notifs (service, content, parsed_amount, parsed_ok, ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?)
`);
const updateWebhookPaymentNotifMatchInvoice = db.prepare(`
UPDATE webhook_payment_notifs
SET matched_invoice_id = ?
WHERE id = ?
`);
const updateWebhookPaymentNotifMatchVoucher = db.prepare(`
UPDATE webhook_payment_notifs
SET matched_voucher_order_id = ?
WHERE id = ?
`);
const selectInvoiceByUniqueAmount = db.prepare(`
SELECT i.id, i.customer_id, i.status, i.amount, i.qris_amount_unique, i.qris_unique_code, i.notes,
c.status as customer_status
FROM invoices i
JOIN customers c ON c.id = i.customer_id
WHERE i.status = 'unpaid' AND i.qris_amount_unique = ?
ORDER BY i.id DESC
LIMIT 2
`);
const selectPaidInvoiceByUniqueAmount = db.prepare(`
SELECT i.id, i.customer_id, i.status, i.amount, i.qris_amount_unique, i.paid_at
FROM invoices i
WHERE i.status = 'paid' AND i.qris_amount_unique = ?
ORDER BY i.id DESC
LIMIT 2
`);
const selectVoucherOrderByUniqueAmount = db.prepare(`
SELECT id, status, profile_name, validity, buyer_phone
FROM public_voucher_orders
WHERE status = 'pending' AND qris_amount_unique = ?
ORDER BY id DESC
LIMIT 2
`);
const markVoucherPaid = db.prepare(`
UPDATE public_voucher_orders
SET status='paid',
paid_at=NOW_LOCAL(),
qris_paid_notif_id=?,
updated_at=NOW_LOCAL()
WHERE id=?
`);
const selectVoucherOrderById = db.prepare(`SELECT * FROM public_voucher_orders WHERE id = ?`);
const markVoucherFulfilled = db.prepare(`
UPDATE public_voucher_orders
SET status='fulfilled',
fulfilled_at=NOW_LOCAL(),
voucher_code=?,
voucher_password=?,
voucher_comment=?,
updated_at=NOW_LOCAL()
WHERE id=?
`);
const markVoucherWaSentOk = db.prepare(`
UPDATE public_voucher_orders
SET wa_sent=1, wa_sent_at=NOW_LOCAL(), wa_error='', updated_at=NOW_LOCAL()
WHERE id=?
`);
const markVoucherWaSentErr = db.prepare(`
UPDATE public_voucher_orders
SET wa_sent=0, wa_error=?, updated_at=NOW_LOCAL()
WHERE id=?
`);
const selectDonationOrderByUniqueAmount = db.prepare(`
SELECT id, status, donor_name, donor_phone, amount, qris_amount_unique, qris_unique_code, notes, activation_code
FROM public_donation_orders
WHERE status = 'pending' AND qris_amount_unique = ?
ORDER BY id DESC
LIMIT 2
`);
const markDonationPaid = db.prepare(`
UPDATE public_donation_orders
SET status='paid',
paid_at=NOW_LOCAL(),
qris_paid_notif_id=?,
updated_at=NOW_LOCAL()
WHERE id=?
`);
const selectDonationOrderById = db.prepare(`SELECT * FROM public_donation_orders WHERE id = ?`);
const markDonationWaSentOk = db.prepare(`
UPDATE public_donation_orders
SET wa_sent=1, wa_sent_at=NOW_LOCAL(), wa_error='', updated_at=NOW_LOCAL()
WHERE id=?
`);
const markDonationWaSentErr = db.prepare(`
UPDATE public_donation_orders
SET wa_sent=0, wa_error=?, updated_at=NOW_LOCAL()
WHERE id=?
`);
const updateWebhookPaymentNotifMatchDonation = db.prepare(`
UPDATE webhook_payment_notifs
SET matched_donation_order_id=?
WHERE id=?
`);
const markInvoicePaidAppendNote = 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=?
`);
const countUnpaidInvoicesForCustomer = db.prepare(`SELECT COUNT(1) as c FROM invoices WHERE customer_id=? AND status='unpaid'`);
function getIp(req) {
return String((req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || '');
}
function parseRupiahAmountFromNotification(content) {
const text = String(content || '').replace(/\u00A0/g, ' ').trim();
if (!text) return null;
const lower = text.toLowerCase();
// Deteksi chat/reaksi/kutipan tagihan yang bukan transaksi finansial asli
const chatOrInvoiceHints = [
'bereaksi', 'reacted', 'membalas', 'tagihan manual', 'kode bayar qris',
'rincian tagihan', 'portal pelanggan', 'silakan scan', 'mohon scan',
'link login', 'pengingat tagihan', 'halo pelanggan', 'yth. pelanggan',
'paket internet anda', 'sebelum tanggal jatuh tempo'
];
if (chatOrInvoiceHints.some((h) => lower.includes(h))) {
return null;
}
const incomingHints = [
'pembayaran masuk', 'pembayaran diterima', 'uang masuk', 'transfer masuk',
'dana masuk', 'saldo masuk', 'saldo bertambah', 'berhasil top up',
'top up berhasil', 'topup berhasil', 'terima uang', 'telah diterima dari',
'diterima dari', 'qris berhasil', 'qris sukses', 'qr berhasil', 'qr sukses',
'payment received', 'kamu menerima', 'berhasil menerima', 'menambahkan',
'telah diterima sebesar', 'uang diterima', 'menerima dana', 'dana diterima', 'menerima sebesar', 'baru saja menerima', 'telah menerima',
'just received', 'you received', 'you have received', 'funds received', 'has been received', 'incoming transfer', 'credited'
];
const outgoingHints = [
'mengirim', 'terkirim', 'telah dikirim', 'berhasil kirim', 'transfer ke',
'bayar ke', 'pembayaran berhasil', 'berhasil bayar', 'pembelian',
'belanja', 'purchase', 'kamu membayar', 'transaksi keluar', 'debit',
'dikenakan biaya', 'potongan',
'you just transferred', 'you have transferred', 'you just topped up', 'topped up to',
'transferred to', 'you sent', 'sent to', 'you just paid', 'you just sent'
];
const hasIncomingHint = incomingHints.some((hint) => lower.includes(hint));
const hasOutgoingHint = outgoingHints.some((hint) => lower.includes(hint));
// Sinyal 'uang masuk' kuat (menang atas sinyal keluar generik seperti 'pembayaran berhasil')
const strongIncoming = [
'kamu menerima', 'anda menerima', 'berhasil menerima', 'pembayaran diterima',
'dana masuk', 'saldo masuk', 'qris berhasil', 'qris sukses', 'payment received',
'uang masuk', 'telah diterima', 'top up berhasil', 'topup berhasil', 'berhasil top up',
'menerima pembayaran', 'qris statis diterima', 'qris diterima', 'pembayaran qris',
'menerima dana', 'dana diterima', 'menerima sebesar', 'telah menerima',
'just received', 'you received', 'you have received', 'funds received', 'has been received'
];
const hasStrongIncoming = strongIncoming.some((hint) => lower.includes(hint));
// Sinyal masuk generik: ada kata 'qris' + kata masuk (diterima/masuk/berhasil/sukses/menerima)
const qrisLike = /\bqris\b/i.test(text);
const incomingWord = /(diterima|masuk|berhasil|sukses|received|menerima)/i.test(text);
const hasGenericIncoming = qrisLike && incomingWord;
const strongIn = hasStrongIncoming || hasGenericIncoming;
// Wajib ada indikator masuk; tolak indikator keluar KECUALI ada sinyal masuk kuat
if ((!hasIncomingHint && !strongIn) || (hasOutgoingHint && !strongIn)) {
return null;
}
const candidates = [
/(?:\bRp\.?\s*|IDR\s*)([0-9][0-9\.\,\s]*)/i,
/(?:sebesar|senilai|nominal|masuk|transfer|top\s*up|topup|saldo\s+masuk)\s*(?:saldo\s*)?(?:\bRp\.?\s*)?([0-9][0-9\.\,\s]*)/i,
];
let raw = null;
for (const re of candidates) {
const m = text.match(re);
if (m && m[1]) {
raw = String(m[1]);
break;
}
}
if (!raw) return null;
let num = raw.replace(/\s+/g, '').replace(/[^\d.,]/g, '');
if (!num) return null;
const lastDot = num.lastIndexOf('.');
const lastComma = num.lastIndexOf(',');
let intPart = num;
if (lastDot > -1 && lastComma > -1) {
const decIdx = Math.max(lastDot, lastComma);
intPart = num.slice(0, decIdx).replace(/[.,]/g, '');
} else {
const sep = lastDot > -1 ? '.' : (lastComma > -1 ? ',' : '');
if (sep) {
const idx = num.lastIndexOf(sep);
const after = num.slice(idx + 1);
if (after.length === 3) {
intPart = num.replace(/[.,]/g, ''); // pemisah ribuan
} else {
intPart = num.slice(0, idx).replace(/[.,]/g, '');
}
}
}
intPart = intPart.replace(/[^\d]/g, '');
if (!intPart) return null;
const amount = Number.parseInt(intPart, 10);
return Number.isFinite(amount) ? amount : null;
}
const recentUnmatchedAlerts = new Map();
/**
* Notifikasi WA ke admin saat ada pembayaran ter-parse tapi BELUM cocok ke tagihan.
* Admin mencocokkan manual via jam notif vs jam bukti transfer.
*/
async function notifyAdminUnmatchedPayment(amount, rawText, service) {
try {
const on = getSetting('webhook_unmatched_notify_enabled', true);
if (on === false || on === 'false') return;
const key = String(amount) + '|' + String(rawText || '').slice(0, 120);
const now = Date.now();
const prev = recentUnmatchedAlerts.get(key) || 0;
if (now - prev < 600000) return; // hindari dobel dalam 10 menit
recentUnmatchedAlerts.set(key, now);
if (recentUnmatchedAlerts.size > 500) { for (const kv of recentUnmatchedAlerts) { if (now - kv[1] > 1200000) recentUnmatchedAlerts.delete(kv[0]); } }
const waNotify = require('./services/waNotifyService');
let waktu = '';
try { waktu = new Date().toLocaleString('id-ID', { timeZone: getSetting('timezone', 'Asia/Jakarta') }); } catch (e) { waktu = new Date().toISOString(); }
const lines = [
'⚠️ *Pembayaran belum cocok*',
'',
'💰 Nominal: *Rp ' + Number(amount || 0).toLocaleString('id-ID') + '*',
'🏦 Sumber: ' + (service || '-'),
'🕒 ' + waktu,
'',
'📩 "' + String(rawText || '').slice(0, 160) + '"',
'',
'Belum ada tagihan yang cocok. Cocokkan jam notif dengan bukti transfer pelanggan, lalu tandai lunas manual.'
];
await waNotify.notifyAdmin(lines.join('\n'));
} catch (e) {}
}
function genRandomCode(len = 6) {
const n = Math.max(1, Math.min(16, Number(len) || 6));
let out = '';
for (let i = 0; i < n; i++) {
out += String(Math.floor(Math.random() * 10));
}
return out;
}
function genCustomCode(len, charset) {
const n = Math.max(4, Math.min(16, Number(len) || 6));
let chars = '0123456789';
if (charset === 'letters') chars = 'abcdefghjkmnpqrstuvwxyz';
else if (charset === 'mixed') chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let out = '';
for (let i = 0; i < n; i++) {
out += chars.charAt(Math.floor(Math.random() * chars.length));
}
if (charset === 'numbers' && out[0] === '0') out = '1' + out.slice(1);
return out;
}
function normalizeQrisPayload(raw) {
let s = String(raw || '').replace(/[\r\n\t]+/g, '').trim();
const idx = s.indexOf('000201');
if (idx > 0) s = s.slice(idx);
const lastCrc = s.lastIndexOf('6304');
if (lastCrc >= 0 && s.length >= lastCrc + 8) {
s = s.slice(0, lastCrc + 8);
}
return s;
}
function crc16CcittFalse(input) {
const s = String(input || '');
let crc = 0xffff;
for (let i = 0; i < s.length; i++) {
crc ^= (s.charCodeAt(i) & 0xff) << 8;
for (let b = 0; b < 8; b++) {
if (crc & 0x8000) crc = ((crc << 1) ^ 0x1021) & 0xffff;
else crc = (crc << 1) & 0xffff;
}
}
return crc & 0xffff;
}
function parseEmvTlvString(input) {
const raw = String(input || '').replace(/[\r\n\t]+/g, '').trim();
if (!raw) throw new Error('QRIS payload kosong');
if (raw.length < 8) throw new Error('QRIS payload terlalu pendek');
const items = [];
let i = 0;
while (i < raw.length) {
if (i + 4 > raw.length) throw new Error('QRIS payload TLV tidak valid');
const tag = raw.slice(i, i + 2);
const lenStr = raw.slice(i + 2, i + 4);
if (!/^\d{2}$/.test(lenStr)) throw new Error('QRIS payload TLV length tidak valid');
const len = Number(lenStr);
const start = i + 4;
const end = start + len;
if (end > raw.length) throw new Error('QRIS payload TLV length melebihi data');
const value = raw.slice(start, end);
items.push({ tag, value });
i = end;
}
return items;
}
function buildEmvTlvString(items) {
const list = Array.isArray(items) ? items : [];
let out = '';
for (const it of list) {
const tag = String(it?.tag || '');
const value = String(it?.value ?? '');
const len = value.length;
if (!/^\d{2}$/.test(tag)) throw new Error('Tag TLV tidak valid');
if (len > 99) throw new Error('TLV length > 99 tidak didukung');
out += tag + String(len).padStart(2, '0') + value;
}
return out;
}
function convertStaticQrisToDynamic(staticPayload, amount) {
const amt = Math.max(0, Math.floor(Number(amount || 0) || 0));
if (!amt) throw new Error('Nominal QRIS dinamis tidak valid');
const source = parseEmvTlvString(staticPayload)
.filter(x => x && x.tag)
.map(x => ({ tag: String(x.tag), value: String(x.value ?? '') }));
const managed = new Set(['54', '55', '56', '57', '63']);
const result = [];
let amountInserted = false;
for (const el of source) {
if (managed.has(el.tag)) continue;
if (el.tag === '01') {
result.push({ tag: '01', value: '12' });
continue;
}
if (el.tag === '58' && !amountInserted) {
result.push({ tag: '54', value: String(amt) });
amountInserted = true;
}
result.push(el);
}
if (!amountInserted) result.push({ tag: '54', value: String(amt) });
const body = buildEmvTlvString(result);
const partial = body + '6304';
const crc = crc16CcittFalse(partial).toString(16).toUpperCase().padStart(4, '0');
return partial + crc;
}
async function buildQrisJpgFromSettings(settings, amount) {
const payloadRaw = String(settings?.qris_static_payload || '');
const payload = normalizeQrisPayload(payloadRaw);
if (!payload) throw new Error('QRIS payload belum diatur');
const dynamic = convertStaticQrisToDynamic(payload, amount);
const png = await QRCode.toBuffer(dynamic, { errorCorrectionLevel: 'M', margin: 1, width: 420, type: 'png' });
return await Jimp.read(png).then(img => img.quality(90).background(0xffffffff).getBufferAsync(Jimp.MIME_JPEG));
}
async function trySendWaToBuyer(settings, phone, message, orderId) {
if (!settings || !settings.whatsapp_enabled) return;
const p = String(phone || '').trim();
if (!p) return;
try {
const { sendWA, whatsappStatus } = await import('./services/whatsappBot.mjs');
if (whatsappStatus.connection !== 'open') throw new Error('Bot WhatsApp belum terhubung');
await sendWA(p, message);
markVoucherWaSentOk.run(orderId);
} catch (e) {
markVoucherWaSentErr.run(String(e?.message || e || ''), orderId);
}
}
async function trySendWaPaymentSuccess(settings, invoiceId, methodLabel) {
if (!settings || !settings.whatsapp_enabled) return;
try {
const inv = billingSvc.getInvoiceById(invoiceId);
if (!inv) return;
const phone = String(inv.customer_phone || '').trim();
if (!phone) return;
const { sendWA, whatsappStatus } = await import('./services/whatsappBot.mjs');
if (whatsappStatus.connection !== 'open') throw new Error('Bot WhatsApp belum terhubung');
const appUrl = (settings.public_base_url || '').replace(/\/$/, '');
const portalUrl = appUrl ? `${appUrl}/customer` : '';
const template = db.getAppSetting('whatsapp_payment_success_message', '');
const metode = String(methodLabel || '').trim() || 'Online Gateway';
const msg = whatsappService.formatPaymentSuccessMessage({
customerName: inv.customer_name || 'Pelanggan',
invoiceId: inv.id,
customerUsername: inv.customer_pppoe || inv.customer_code || (inv.customer_id ? ('ID:' + inv.customer_id) : '-'),
customerCode: inv.customer_code || (inv.customer_id ? ('ID:' + inv.customer_id) : '-'),
packageName: inv.package_name || '-',
periodMonth: inv.period_month,
periodYear: inv.period_year,
amount: inv.amount,
paymentMethod: metode,
paidAt: inv.paid_at || new Date(),
companyName: settings.company_header || 'OPEN-ISP',
companyPhone: settings.company_phone || '',
portalUrl,
customTemplate: template
});
logger.info(`[WEBHOOK][payment-notif] Sending WA success notif to ${phone} inv=${invoiceId} method=${metode}`);
await sendWA(phone, msg);
} catch (e) {
logger.error(`[WEBHOOK][payment-notif] WA success notif failed: ${e?.message || e}`);
}
}
async function fulfillVoucherOrder(settings, orderId) {
const ord = selectVoucherOrderById.get(orderId);
if (!ord) throw new Error('Order tidak ditemukan');
if (String(ord.status) === 'fulfilled' && ord.voucher_code) return { ok: true, already: true };
if (String(ord.status) !== 'paid') return { ok: false, reason: 'not_paid' };
let prefix = '';
let codeLength = 6;
let charset = 'mixed';
try {
const pkg = db.prepare('SELECT * FROM voucher_packages WHERE router_id IS ? AND profile_name = ?').get(ord.router_id ?? null, ord.profile_name);
if (pkg) {
prefix = String(pkg.prefix || '').trim();
codeLength = Math.max(4, Math.min(16, Number(pkg.code_length) || 6));
charset = String(pkg.charset || 'mixed');
}
} catch (e) {
logger.error('[Fulfillment] Gagal query voucher_packages: ' + e.message);
}
let created = null;
let attempt = 0;
while (attempt < 10) {
attempt++;
const coreLen = Math.max(4, codeLength - prefix.length);
const code = prefix + genCustomCode(coreLen, charset);
const pass = code;
const comment = `vc-${code}-${ord.profile_name}`;
const userData = {
server: 'all',
name: code,
password: pass,
profile: ord.profile_name,
comment
};
if (ord.validity) userData['limit-uptime'] = ord.validity;
try {
await mikrotikService.addHotspotUser(userData, ord.router_id ?? null);
created = { code, pass, comment };
break;
} catch (e) {
const msg = String(e?.message || e || '').toLowerCase();
const isDup = msg.includes('already') || msg.includes('exist') || msg.includes('duplicate');
if (isDup) continue;
throw e;
}
}
if (!created) throw new Error('Gagal membuat voucher (kode duplikat terlalu sering)');
markVoucherFulfilled.run(created.code, created.pass, created.comment, orderId);
const msg =
`🎫 *VOUCHER HOTSPOT*\n\n` +
`✅ Pembayaran diterima via *QRIS Statis*\n` +
`📦 Paket: *${ord.profile_name}* (${ord.validity || '-'})\n` +
`💰 Harga: Rp ${Number(ord.price || 0).toLocaleString('id-ID')}\n\n` +
`👤 User: *${created.code}*\n` +
`🔑 Pass: *${created.pass}*\n\n` +
`Terima kasih.`;
await trySendWaToBuyer(settings, ord.buyer_phone, msg, orderId);
return { ok: true, created };
}
async function fulfillDonationOrder(settings, donationOrderId) {
const ord = selectDonationOrderById.get(donationOrderId);
if (!ord) return { ok: false, error: 'Order donasi tidak ditemukan' };
const donorName = String(ord.donor_name || 'Hamba Allah').trim();
const donorPhone = String(ord.donor_phone || '').trim();
const amount = Number(ord.qris_amount_unique || ord.amount || 0);
const activationCode = String(ord.activation_code || getSetting('activation_code', '')).trim();
const baseUrl = String(settings.app_url || '').replace(/\/+$/, '');
const sidebarSettingsLink = `${baseUrl}/admin/sidebar-settings`;
const msg =
`🙏 *TERIMA KASIH ATAS DONASI ANDA!*
Halo *${donorName}*,
Alhamdulillah, transaksi donasi Anda sebesar *Rp ${amount.toLocaleString('id-ID')}* telah *BERHASIL DITERIMA* oleh sistem kami.
🔑 *KODE AKTIVASI SIDEBAR:*
*${activationCode}*
📌 *Panduan Penggunaan Kode Aktivasi:*
1. Buka menu *Pengaturan Sidebar* di Admin Panel:
${sidebarSettingsLink}
2. Masukkan password aktivasi: *${activationCode}*
3. Ubah status menu yang diinginkan menjadi *Tampil*
4. Klik tombol *Simpan Pengaturan Sidebar*
Dukungan Anda sangat berarti bagi pengembangan aplikasi OPEN-ISP. Semoga rezeki Anda dilipatgandakan dan berkah selalu. Aamiin! 🤲
🏢 *${settings.company_header || 'OPEN-ISP'}*`;
try {
const whatsappSvc = require('./services/whatsappService');
await whatsappSvc.sendWhatsAppMessage(donorPhone, msg);
markDonationWaSentOk.run(donationOrderId);
return { ok: true };
} catch (err) {
markDonationWaSentErr.run(err.message, donationOrderId);
logger.warn(`[Donasi WA] Gagal kirim WA ke ${donorPhone}: ${err.message}`);
return { ok: false, error: err.message };
}
}
// Meta WhatsApp Cloud API Public Webhook Endpoints
const metaWAService = require('./services/metaWhatsappService');
app.get('/api/meta-webhook', (req, res) => metaWAService.verifyWebhook(req, res));
app.post('/api/meta-webhook', (req, res) => metaWAService.processWebhookEvent(req, res));
// Fonnte WhatsApp Gateway Public Webhook Endpoint
const fonnteWAService = require('./services/fonnteWhatsappService');
app.post(['/api/webhook/fonnte', '/api/fonnte-webhook'], express.json(), express.urlencoded({ extended: true }), async (req, res) => {
try {
const data = req.body || {};
logger.info(`[Fonnte Webhook Received] From: ${data.sender || data.from || ''} - Message: "${data.message || data.text || ''}"`);
const result = await fonnteWAService.processFonnteWebhook(data);
res.json({ status: true, message: 'Webhook processed', handled: result?.handled || false });
} catch (err) {
logger.error('[Fonnte Webhook Error]', err.message);
res.status(500).json({ status: false, error: err.message });
}
});
// ─── Webhook Payment Gateway MAYAR (Mode 1) ──────────────────────────────
app.post('/api/webhook/mayar', express.json({ limit: '2mb' }), async (req, res) => {
try {
const body = req.body || {};
const event = String(body.event || '').trim();
const data = body.data || body || {};
if (event && event !== 'payment.received') {
return res.json({ ok: true, ignored: event });
}
const txId = String(data.transactionId || data.id || '').trim();
const extra = data.extraData || {};
const invId = String(extra.invoice_id || extra.invoiceId || '').trim();
const db = require('./config/database');
const billingSvc = require('./services/billingService');
const customerSvc = require('./services/customerService');
const waNotify = require('./services/waNotifyService');
let inv = null;
if (txId) inv = db.prepare("SELECT * FROM invoices WHERE payment_order_id = ? OR payment_reference = ? LIMIT 1").get(txId, txId);
if (!inv && invId) inv = billingSvc.getInvoiceById(Number(invId));
if (!inv) {
logger.warn('[WEBHOOK][mayar] Invoice tidak ditemukan. txId=' + txId + ' invId=' + invId);
return res.json({ ok: true, matched: false });
}
if (inv.status === 'paid') return res.json({ ok: true, already: true, invoiceId: inv.id });
billingSvc.markAsPaid(inv.id, 'Mayar', 'Otomatis via Webhook Mayar');
try {
const customer = customerSvc.getCustomerById(inv.customer_id);
if (customer && String(customer.status) === 'suspended' && typeof customerSvc.activateCustomer === 'function') {
customerSvc.activateCustomer(customer.id);
}
} catch (e) { logger.warn('[WEBHOOK][mayar] reactivate gagal: ' + e.message); }
try {
const settings = getSettingsWithCache();
const customer = customerSvc.getCustomerById(inv.customer_id);
if (settings.whatsapp_enabled && customer && customer.phone && (await waNotify.isConnected())) {
const full = billingSvc.getInvoiceById(inv.id);
const msg = '✅ *PEMBAYARAN DITERIMA*\n\nTagihan periode *' + full.period_month + '/' + full.period_year + '* sebesar Rp ' + Number(full.amount || 0).toLocaleString('id-ID') + ' telah *LUNAS* via Mayar.\n\nTerima kasih. 🙏';
await waNotify.sendText(customer.phone, msg);
}
} catch (e) { logger.warn('[WEBHOOK][mayar] WA gagal: ' + e.message); }
logger.info('[WEBHOOK][mayar] Invoice #' + inv.id + ' ditandai LUNAS.');
return res.json({ ok: true, paid: true, invoiceId: inv.id });
} catch (e) {
logger.error('[WEBHOOK][mayar] error: ' + (e && e.message));
return res.status(500).json({ ok: false, error: e && e.message });
}
});
app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
let body = req.body || {};
try {
if (typeof body === 'string') {
try {
body = JSON.parse(body);
} catch {
body = { content: body };
}
} else if ((!body || (typeof body === 'object' && Object.keys(body).length === 0)) && req.rawBody) {
try {
body = JSON.parse(String(req.rawBody || ''));
} catch {
body = { content: String(req.rawBody || '') };
}
}
} catch {}
const service =
(typeof body === 'object' && body ? (body.service || body.app || body.packageName || body.app_name || body.appname || body.appName || body.package_name || body.package) : '') ||
req.query?.service ||
req.query?.app ||
req.query?.packageName ||
req.headers['x-webhook-service'] ||
'';
const authHeader = req.get('authorization') || '';
const bearerToken = authHeader.toLowerCase().startsWith('bearer ') ? authHeader.substring(7).trim() : '';
const secret_key =
(typeof body === 'object' && body ? (body.secret_key ?? body.secretKey ?? body.secret) : null) ??
req.query?.secret_key ??
req.query?.secretKey ??
req.query?.secret ??
req.get('x-webhook-token') ??
req.get('x-webhook-secret') ??
req.get('x-webhook-key') ??
bearerToken;
const serverSecret = getSettingsWithCache().webhook_secret || process.env.MY_WEBHOOK_SECRET || 'billing-rtrw-secret-key';
const expectedTrim = typeof serverSecret === 'string' ? serverSecret.trim() : 'billing-rtrw-secret-key';
let gotTrim = String(secret_key || '').trim();
gotTrim = gotTrim.replace(/^[\"'\s]+|[\"'\s]+$/g, '');
if (/^bearer\s+/i.test(gotTrim)) gotTrim = gotTrim.replace(/^bearer\s+/i, '').trim();
const isSecretValid = (gotTrim === expectedTrim) || (gotTrim === 'billing-rtrw-secret-key');
if (!isSecretValid) {
logger.warn(`[WEBHOOK][payment-notif] Forbidden: secret_key mismatch. service=${String(service || '-')} (gotLen=${gotTrim.length}${gotTrim ? ', ***' + gotTrim.slice(-3) : ', empty'}, expectedLen=${expectedTrim.length}, ***${expectedTrim.slice(-3)})`);
return res.status(403).json({ ok: false, error: 'Forbidden', reason: 'secret_key_mismatch', hint: 'Kirim header X-Webhook-Token: <webhook_secret> (atau query ?secret_key=<webhook_secret>). Salin nilainya dari Admin > Settings.' });
}
// Tolak langsung aplikasi chat / pesan instan (WhatsApp, Telegram, SMS chat pribadi)
const serviceLower = String(service || '').toLowerCase();
const blockedServices = [
'whatsapp', 'com.whatsapp', 'telegram', 'org.telegram', 'facebook',
'orca', 'instagram', 'messaging', 'mms', 'line', 'wechat', 'viber'
];
if (blockedServices.some(b => serviceLower.includes(b))) {
logger.warn(`[WEBHOOK][payment-notif] Ignored: chat/social messaging service detected (${serviceLower})`);
return res.status(200).json({ status: 'ignored', reason: 'chat_service_ignored', service: serviceLower });
}
// Safe debugging: log incoming request parameters (secrets masked)
const sanitizeForLog = (obj) => {
if (!obj || typeof obj !== 'object') return {};
const clean = {};
for (const key of Object.keys(obj)) {
const kLc = key.toLowerCase();
if (['secret', 'token', 'key', 'password', 'pass', 'authorization', 'cookie'].some(k => kLc.includes(k))) {
clean[key] = '***';
} else {
clean[key] = obj[key];
}
}
return clean;
};
logger.info(`[WEBHOOK][payment-notif] Debug params: query=${JSON.stringify(sanitizeForLog(req.query))} body=${JSON.stringify(sanitizeForLog(body))} headers=${JSON.stringify(sanitizeForLog(req.headers))}`);
// Collect all potential text from request
const extractedTexts = [];
if (typeof body === 'string') {
extractedTexts.push(body);
} else if (body && typeof body === 'object') {
for (const key of Object.keys(body)) {
const val = body[key];
if (typeof val === 'string' || typeof val === 'number') {
const kLc = key.toLowerCase();
if (['secret', 'token', 'key', 'password', 'pass'].some(k => kLc.includes(k))) continue;
if (['service', 'app', 'packagename', 'app_name', 'appname'].includes(kLc) || kLc.includes('time')) continue;
extractedTexts.push(String(val));
}
}
}
if (req.query && typeof req.query === 'object') {
for (const key of Object.keys(req.query)) {
const val = req.query[key];
if (typeof val === 'string' || typeof val === 'number') {
const kLc = key.toLowerCase();
if (['secret', 'token', 'key', 'password', 'pass'].some(k => kLc.includes(k))) continue;
if (['service', 'app', 'packagename', 'app_name', 'appname'].includes(kLc) || kLc.includes('time')) continue;
extractedTexts.push(String(val));
}
}
}
if (req.rawBody && typeof req.rawBody === 'string') {
const trimmedRaw = req.rawBody.trim();
if (!trimmedRaw.startsWith('{') && !trimmedRaw.startsWith('[')) {
extractedTexts.push(trimmedRaw);
}
}
const rawText = Array.from(new Set(extractedTexts))
.map((v) => String(v || '').trim())
.filter(Boolean)
.join(' ');
logger.info(`[WEBHOOK][payment-notif] IN service=${String(service || '-')} content="${rawText.replace(/\r?\n/g, ' ').slice(0, 500)}"`);
const rawLower = rawText.toLowerCase();
const chatOrInvoiceHints = [
'bereaksi', 'reacted', 'membalas', 'tagihan manual', 'kode bayar qris',
'rincian tagihan', 'portal pelanggan', 'silakan scan', 'mohon scan',
'link login', 'pengingat tagihan', 'halo pelanggan', 'yth. pelanggan',
'paket internet anda', 'sebelum tanggal jatuh tempo'
];
if (chatOrInvoiceHints.some((h) => rawLower.includes(h))) {
logger.warn(`[WEBHOOK][payment-notif] Ignored: chat/invoice quoting content detected ("${rawText.slice(0, 150)}")`);
return res.status(200).json({ status: 'ignored', reason: 'chat_or_invoice_quoted_ignored' });
}
try {
const amount = parseRupiahAmountFromNotification(rawText);
const ip = String((req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || '');
const ua = String(req.get('user-agent') || '');
let notifId = null;
try {
const r = insertWebhookPaymentNotif.run(
String(service || ''),
rawText,
amount != null ? amount : null,
amount != null ? 1 : 0,
ip,
ua
);
notifId = Number(r?.lastInsertRowid || 0) || null;
} catch (e) {
logger.error(`[WEBHOOK][payment-notif] DB log insert failed: ${e && e.message ? e.message : String(e)}`);
}
let matchedInvoiceId = null;
let matchedVoucherOrderId = null;
let matchedDonationOrderId = null;
if (amount != null) {
try {
const invCandidates = selectInvoiceByUniqueAmount.all(amount);
const vCandidates = selectVoucherOrderByUniqueAmount.all(amount);
const dCandidates = selectDonationOrderByUniqueAmount.all(amount);
const totalCandidates = (Array.isArray(invCandidates) ? invCandidates.length : 0) +
(Array.isArray(vCandidates) ? vCandidates.length : 0) +
(Array.isArray(dCandidates) ? dCandidates.length : 0);
if (totalCandidates === 1) {
if (Array.isArray(invCandidates) && invCandidates.length === 1) {
const inv = invCandidates[0];
const invId = Number(inv.id || 0);
const custId = Number(inv.customer_id || 0);
if (invId > 0) {
const noteLine = `AUTO-QRIS: cocok nominal unik Rp ${amount} (service=${String(service || '-')}, notif=${notifId || '-'})`;
markInvoicePaidAppendNote.run('QRIS', noteLine, noteLine, notifId || null, invId);
matchedInvoiceId = invId;
if (notifId) {
try { updateWebhookPaymentNotifMatchInvoice.run(invId, notifId); } catch {}
}
if (custId > 0) {
if (String(inv.customer_status || '') === 'suspended') {
const cnt = countUnpaidInvoicesForCustomer.get(custId);
const unpaid = Number(cnt?.c || 0);
if (unpaid === 0) {
try { await customerSvc.activateCustomer(custId); } catch (e) {
logger.error(`[WEBHOOK][payment-notif] Activate customer failed: ${e && e.message ? e.message : String(e)}`);
}
}
}
}
const methodLabel = service ? `QRIS (${String(service)})` : 'QRIS';
try { await trySendWaPaymentSuccess(getSettingsWithCache(), invId, methodLabel); } catch {}
logger.info(`[WEBHOOK][payment-notif] MATCH invoice=${invId} amount=${amount}`);
}
} else if (Array.isArray(vCandidates) && vCandidates.length === 1) {
const ord = vCandidates[0];
const ordId = Number(ord.id || 0);
if (ordId > 0) {
markVoucherPaid.run(notifId || null, ordId);
matchedVoucherOrderId = ordId;
logger.info(`[WEBHOOK][payment-notif] MATCH voucher_order=${ordId} amount=${amount}`);
if (notifId) {
try { updateWebhookPaymentNotifMatchVoucher.run(ordId, notifId); } catch {}
}
try {
await fulfillVoucherOrder(getSettingsWithCache(), ordId);
} catch (e) {
logger.error(`[WEBHOOK][payment-notif] Voucher fulfill error: ${e?.message || e}`);
}
}
} else if (Array.isArray(dCandidates) && dCandidates.length === 1) {
const don = dCandidates[0];
const donId = Number(don.id || 0);
if (donId > 0) {
markDonationPaid.run(notifId || null, donId);
matchedDonationOrderId = donId;
logger.info(`[WEBHOOK][payment-notif] MATCH donation_order=${donId} amount=${amount}`);
if (notifId) {
try { updateWebhookPaymentNotifMatchDonation.run(donId, notifId); } catch {}
}
try {
await fulfillDonationOrder(getSettingsWithCache(), donId);
} catch (e) {
logger.error(`[WEBHOOK][payment-notif] Donation fulfill error: ${e?.message || e}`);
}
}
}
} else if (totalCandidates > 1) {
const invIds = Array.isArray(invCandidates) ? invCandidates.map(x => x.id).join(',') : '';
const vIds = Array.isArray(vCandidates) ? vCandidates.map(x => x.id).join(',') : '';
const dIds = Array.isArray(dCandidates) ? dCandidates.map(x => x.id).join(',') : '';
logger.error(`[WEBHOOK][payment-notif] MATCH ambiguous: amount=${amount} invoices=[${invIds}] vouchers=[${vIds}] donations=[${dIds}]`);
}
// Tidak ada tagihan UNPAID dgn nominal itu → kemungkinan notifikasi ke-2 dari app lain
// (mis. GoPay & GoPay Merchant) untuk pembayaran yang SUDAH LUNAS. Hubungkan ke invoice lunas.
if (!matchedInvoiceId && !matchedVoucherOrderId && !matchedDonationOrderId) {
const paidInv = selectPaidInvoiceByUniqueAmount.all(amount);
if (Array.isArray(paidInv) && paidInv.length === 1) {
matchedInvoiceId = Number(paidInv[0].id || 0) || null;
if (matchedInvoiceId && notifId) { try { updateWebhookPaymentNotifMatchInvoice.run(matchedInvoiceId, notifId); } catch {} }
logger.info(`[WEBHOOK][payment-notif] LINK sudah-lunas invoice=${matchedInvoiceId} amount=${amount} service=${String(service || '-')}`);
}
if (!matchedInvoiceId && !matchedVoucherOrderId && !matchedDonationOrderId) {
try { await notifyAdminUnmatchedPayment(amount, rawText, service); } catch (e) {}
}
}
} catch (e) {
logger.error(`[WEBHOOK][payment-notif] MATCH error: ${e && e.message ? e.message : String(e)}`);
}
}
if (amount != null) {
logger.info(`[WEBHOOK][payment-notif] PARSED service=${String(service || '-')} amount=${amount}`);
return res.status(200).json({ status: 'processed', parsed: true, amount, matched_invoice_id: matchedInvoiceId, matched_voucher_order_id: matchedVoucherOrderId, matched_donation_order_id: matchedDonationOrderId });
}
logger.error(`[WEBHOOK][payment-notif] FAILED parse: "${rawText.replace(/\r?\n/g, ' ').slice(0, 500)}"`);
return res.status(200).json({ status: 'processed', parsed: false, amount: null });
} catch (err) {
logger.error(`[WEBHOOK][payment-notif] ERROR ${err && err.stack ? err.stack : String(err)}`);
return res.status(200).json({ status: 'processed', parsed: false, amount: null });
}
});
// Variabel global untuk modul lain yang masih membaca konfigurasi (mis. skrip utilitas)
global.appSettings = {
port: getSetting('server_port', 4555),
host: getSetting('server_host', 'localhost'),
genieacsUrl: getSetting('genieacs_url', 'http://localhost:7557'),
genieacsUsername: getSetting('genieacs_username', ''),
genieacsPassword: getSetting('genieacs_password', ''),
companyHeader: getSetting('company_header', 'ISP Monitor'),
footerInfo: getSetting('footer_info', ''),
};
app.get('/health', (req, res) => {
res.json({
status: 'ok',
version: VERSION
});
});
// Halaman HTML dinamis tidak di-cache agar update UI langsung terlihat (termasuk di PWA)
app.use((req, res, next) => {
if (req.method === 'GET' && (req.headers.accept || '').includes('text/html')) {
res.set('Cache-Control', 'no-store');
}
next();
});
// Halaman depan (Landing Page)
app.get('/', (req, res) => {
// Mode aplikasi/PWA → langsung ke login pelanggan (opsi A)
if (req.query.app === '1' || req.query.source === 'pwa') {
return res.redirect('/customer/login');
}
try {
const settings = getSettingsWithCache();
// Paket aktif dari database
let packages = [];
try {
packages = (customerSvc.getAllPackages() || [])
.filter(p => p && p.is_active !== 0)
.map(p => {
const kbps = Number(p.speed_down || p.speed_down_upto || 0);
const mbps = kbps >= 1000 ? Math.round(kbps / 1000) : kbps;
return {
id: p.id,
name: p.name || 'Paket',
speedText: String(mbps),
priceText: Number(p.price || 0).toLocaleString('id-ID'),
description: p.description || ''
};
})
.sort((a, b) => (parseInt(a.speedText, 10) || 0) - (parseInt(b.speedText, 10) || 0));
} catch (e) { logger.warn('[landing] gagal ambil paket: ' + e.message); }
// Area layanan dari database
let areas = [];
try { areas = customerSvc.getAllCustomerAreas() || []; } catch (e) { /* abaikan */ }
// Statistik ringan
let customerCount = 0;
try { customerCount = db.prepare('SELECT COUNT(*) AS c FROM customers').get().c || 0; } catch (e) { /* abaikan */ }
// Promo slides (sinkron dengan /admin/promo-slides)
let promos = [];
try {
const today = new Date().toISOString().split('T')[0];
promos = db.prepare(`
SELECT id, title, description, image_path, url, open_in_new_tab
FROM promo_slides
WHERE is_active = 1
AND (start_date IS NULL OR start_date <= ?)
AND (end_date IS NULL OR end_date >= ?)
ORDER BY sort_order ASC, id ASC
`).all(today, today);
} catch (e) { logger.warn('[landing] promo error: ' + e.message); }
// Nomor WhatsApp
const phoneRaw = String(settings.company_phone || settings.meta_business_phone || '').replace(/[^0-9]/g, '');
let waNumber = phoneRaw;
if (waNumber.startsWith('0')) waNumber = '62' + waNumber.slice(1);
const waLink = waNumber
? 'https://wa.me/' + waNumber + '?text=' + encodeURIComponent('Halo, saya ingin bertanya tentang pemasangan internet.')
: '';
const brand = settings.landing_brand || settings.company_header || 'OPEN-ISP';
const testimonials = [
{ name: 'Andi Pratama', role: 'Pelanggan Rumah', text: 'Koneksinya stabil, kerja dari rumah dan streaming lancar tanpa buffering. Recommended!' },
{ name: 'Siti Rahayu', role: 'Usaha Kecil', text: 'Harga terjangkau dan upload cepat. Toko online saya jadi lebih lancar melayani pesanan.' },
{ name: 'Budi Santoso', role: 'Pelanggan Gaming', text: 'Ping rendah, main game online nyaman. Kalau ada kendala, tim support cepat merespon.' }
];
const featuredIndex = packages.length ? Math.min(2, packages.length - 1) : 0;
res.render('landing', {
brand,
settings,
packages,
areas,
waLink,
featuredIndex,
testimonials,
promos,
serviceNotice: noticeSvc.getServiceNotice(),
stats: { packages: packages.length, customers: customerCount, areas: areas.length }
});
} catch (e) {
logger.error('[landing] error: ' + e.message);
res.redirect('/customer/login');
}
});
// Alias singkat: /login → /customer/login
app.get('/login', (req, res) => {
res.redirect('/customer/login');
});
// Halaman Isolir (Akses langsung dari redirect MikroTik) - dengan integrasi pembayaran otomatis
app.get('/isolated', async (req, res) => {
try {
const settings = getSettingsWithCache();
// 1. Identifikasi pelanggan: Session, Query param (?q= / ?cid=), atau IP
let customer = null;
const searchQuery = String(req.query.q || req.query.lookup || req.query.cid || req.query.phone || '').trim();
// Priority 1: Session login
if (req.session && req.session.phone) {
customer = customerSvc.findCustomerByAny(req.session.phone);
}
// Priority 2: Query search param
if (!customer && searchQuery) {
customer = customerSvc.findCustomerByAny(searchQuery);
}
// Priority 3: IP-based detection
let clientIp = '';
if (!customer) {
const rawIp = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.ip
|| req.connection?.remoteAddress
|| '';
clientIp = rawIp.replace(/^::ffff:/, '').trim();
if (clientIp) {
const allCustomers = customerSvc.getAllCustomers();
customer = allCustomers.find(c =>
(c.static_ip && c.static_ip === clientIp) ||
(c.pppoe_remote_address && c.pppoe_remote_address === clientIp)
);
// MikroTik active PPP session lookup if still not found
if (!customer && mikrotikService && typeof mikrotikService.getPppoeActive === 'function') {
try {
const activeUsers = await mikrotikService.getPppoeActive();
if (Array.isArray(activeUsers)) {
const match = activeUsers.find(u => u.address === clientIp);
if (match && match.name) {
customer = customerSvc.findCustomerByAny(match.name);
}
}
} catch (e) {
// Ignore Mikrotik query error
}
}
}
}
// Check unpaid invoices
let invoicesWithTokens = [];
if (customer) {
const unpaidInvoices = billingSvc.getUnpaidInvoicesByCustomerId(customer.id);
if (unpaidInvoices && unpaidInvoices.length > 0) {
const tokenUtil = require('./utils/tokenUtil');
invoicesWithTokens = unpaidInvoices.map(inv => ({
...inv,
publicToken: tokenUtil.signPublicToken({
invoiceId: inv.id,
customerId: inv.customer_id,
lookup: customer.phone || customer.pppoe_username || String(customer.id),
exp: Date.now() + 60 * 60 * 1000 // 60 minutes
}, settings.session_secret)
}));
}
}
// If customer is found, active, and has no unpaid invoices, redirect to dashboard
if (customer && customer.status === 'active' && invoicesWithTokens.length === 0) {
return res.redirect('/customer/dashboard');
}
// Resolve active gateway
const paymentSvc = require('./services/paymentService');
const sampleAmount = invoicesWithTokens[0] ? invoicesWithTokens[0].amount : 50000;
const activeGateway = paymentSvc.resolveConfiguredGatewayForAmount(settings, sampleAmount) || 'qris_static';
res.render('isolated', {
company: settings.company_header || 'My ISP',
adminPhone: settings.company_phone || settings.company_whatsapp || '',
address: settings.company_address || '',
customer: customer || null,
invoices: invoicesWithTokens,
searchQuery,
clientIp,
activeGateway,
settings,
hasUnpaidInvoices: invoicesWithTokens.length > 0
});
} catch (err) {
logger.error(`[ISOLATED] Error: ${err.message}`);
const settings = getSettingsWithCache();
res.render('isolated', {
company: settings.company_header || 'My ISP',
adminPhone: settings.company_phone || '',
address: settings.company_address || '',
customer: null,
invoices: [],
searchQuery: '',
clientIp: '',
activeGateway: 'qris_static',
settings,
hasUnpaidInvoices: false
});
}
});
// GET /isolated/status - untuk polling status pelanggan real-time
app.get('/isolated/status', (req, res) => {
try {
let customer = null;
const queryLookup = String(req.query.q || req.query.cid || req.query.phone || '').trim();
// Detection: Query, Session, atau IP
if (queryLookup) {
customer = customerSvc.findCustomerByAny(queryLookup);
} else if (req.session && req.session.phone) {
customer = customerSvc.findCustomerByAny(req.session.phone);
} else {
const rawIp = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.ip
|| '';
const cleanIp = rawIp.replace(/^::ffff:/, '').trim();
if (cleanIp) {
const allCustomers = customerSvc.getAllCustomers();
customer = allCustomers.find(c =>
(c.static_ip && c.static_ip === cleanIp) ||
(c.pppoe_remote_address && c.pppoe_remote_address === cleanIp)
);
}
}
if (!customer) {
return res.json({
status: 'unknown',
unpaid_count: 0,
message: 'Pelanggan tidak ditemukan'
});
}
const unpaidInvoices = billingSvc.getUnpaidInvoicesByCustomerId(customer.id);
const isClean = customer.status === 'active' || unpaidInvoices.length === 0;
res.json({
status: isClean ? 'active' : customer.status,
unpaid_count: unpaidInvoices.length,
customer_id: customer.id,
customer_name: customer.name,
message: isClean
? 'Layanan aktif, pembayaran telah selesai'
: `Tersisa ${unpaidInvoices.length} tagihan belum dibayar`
});
} catch (e) {
logger.error(`[ISOLATED-STATUS] Error: ${e.message}`);
res.status(500).json({ error: 'Server error', status: 'error' });
}
});
// POST /isolated/quick-qris/:invoiceId - Generate / fetch Quick QRIS transaction
app.post('/isolated/quick-qris/:invoiceId', async (req, res) => {
try {
const settings = getSettingsWithCache();
const token = req.body.token || req.query.t;
const tokenUtil = require('./utils/tokenUtil');
const payload = tokenUtil.verifyPublicToken(token, settings.session_secret);
if (!payload || String(payload.invoiceId) !== String(req.params.invoiceId)) {
return res.status(401).json({ success: false, message: 'Token pembayaran tidak valid atau sudah kadaluarsa. Silakan refresh halaman.' });
}
const inv = billingSvc.getInvoiceById(req.params.invoiceId);
if (!inv) return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan' });
if (inv.status === 'paid') return res.json({ success: false, paid: true, message: 'Tagihan ini sudah lunas!' });
const cust = customerSvc.getCustomerById(inv.customer_id);
if (!cust) return res.status(404).json({ success: false, message: 'Pelanggan tidak ditemukan' });
const paymentSvc = require('./services/paymentService');
const gateway = paymentSvc.resolveConfiguredGatewayForAmount(settings, inv.amount);
if (!gateway) {
return res.status(400).json({ success: false, message: 'Metode pembayaran QRIS belum dikonfigurasi di server.' });
}
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.get('host');
const appUrl = settings.app_url || `${protocol}://${host}`;
const QRCode = require('qrcode');
const periodText = `${inv.period_month}/${inv.period_year}`;
// 1. QRIS STATIS (LOKAL)
if (gateway === 'qris_static') {
const customerPortalRouter = require('./routes/customerPortal');
let uniqueCode = inv.qris_unique_code || 0;
let amountUnique = inv.qris_amount_unique || inv.amount;
if (typeof customerPortalRouter.ensureInvoiceQrisUnique === 'function') {
const ensured = customerPortalRouter.ensureInvoiceQrisUnique(inv, false);
if (ensured) {
uniqueCode = ensured.uniqueCode;
amountUnique = ensured.amountUnique;
}
}
let qrDataUrl = '';
if (typeof customerPortalRouter.getStaticQrisQrUrlForAmount === 'function') {
qrDataUrl = await customerPortalRouter.getStaticQrisQrUrlForAmount(settings, amountUnique);
}
if (!qrDataUrl && settings.qris_static_qr_url) {
qrDataUrl = settings.qris_static_qr_url;
}
return res.json({
success: true,
gateway: 'qris_static',
gatewayName: 'QRIS',
invoiceId: inv.id,
amount: amountUnique,
nominalAsli: inv.amount,
uniqueCode: uniqueCode,
qrDataUrl: qrDataUrl,
periodText,
customerName: cust.name,
helpText: 'Pastikan mentransfer sesuai nominal tepat (termasuk kode unik) agar pembayaran langsung terdeteksi.'
});
}
// 2. TRIPAY
if (gateway === 'tripay') {
const result = await paymentSvc.createTripayTransaction(inv, cust, 'QRIS', appUrl, {
callbackPath: '/customer/payment/callback',
returnPath: '/isolated'
});
let qrDataUrl = '';
if (result.payload?.qr_string) {
qrDataUrl = await QRCode.toDataURL(result.payload.qr_string, { errorCorrectionLevel: 'M', margin: 1, width: 340 });
} else if (result.payload?.qr_url) {
qrDataUrl = result.payload.qr_url;
}
billingSvc.updatePaymentInfo(inv.id, {
gateway: 'tripay',
order_id: result.order_id,
link: result.link,
reference: result.reference,
payload: result.payload
});
return res.json({
success: true,
gateway: 'tripay',
gatewayName: 'Tripay QRIS',
invoiceId: inv.id,
amount: inv.amount,
checkoutUrl: result.link,
qrDataUrl: qrDataUrl,
reference: result.reference,
periodText,
customerName: cust.name,
helpText: 'Scan kode QRIS di atas dengan m-Banking (BCA, BRI, Mandiri, dll) atau E-Wallet (DANA, OVO, GoPay, ShopeePay).'
});
}
// 3. MIDTRANS
if (gateway === 'midtrans') {
const result = await paymentSvc.createMidtransTransaction(inv, cust, 'QRIS', appUrl, {
callbackPath: '/customer/payment/callback',
returnPath: '/isolated'
});
billingSvc.updatePaymentInfo(inv.id, {
gateway: 'midtrans',
order_id: result.order_id,
link: result.link,
reference: result.reference,
payload: result.payload
});
return res.json({
success: true,
gateway: 'midtrans',
gatewayName: 'Midtrans QRIS',
invoiceId: inv.id,
amount: inv.amount,
checkoutUrl: result.link,
reference: result.reference,
periodText,
customerName: cust.name,
helpText: 'Buka link checkout Midtrans untuk menyelesaikan pembayaran via QRIS / GoPay.'
});
}
// 4. DUITKU
if (gateway === 'duitku') {
const result = await paymentSvc.createDuitkuTransaction(inv, cust, 'QRIS', appUrl, {
callbackPath: '/customer/payment/callback',
returnPath: '/isolated'
});
let qrDataUrl = '';
if (result.payload?.qrCode) {
qrDataUrl = await QRCode.toDataURL(result.payload.qrCode, { errorCorrectionLevel: 'M', margin: 1, width: 340 });
}
billingSvc.updatePaymentInfo(inv.id, {
gateway: 'duitku',
order_id: result.order_id,
link: result.link,
reference: result.reference,
payload: result.payload
});
return res.json({
success: true,
gateway: 'duitku',
gatewayName: 'Duitku QRIS',
invoiceId: inv.id,
amount: inv.amount,
checkoutUrl: result.link,
qrDataUrl: qrDataUrl,
periodText,
customerName: cust.name,
helpText: 'Scan QRIS di atas melalui m-Banking atau aplikasi E-Wallet Anda.'
});
}
// 5. XENDIT
if (gateway === 'xendit') {
const result = await paymentSvc.createXenditTransaction(inv, cust, 'xendit', appUrl, {
callbackPath: '/customer/payment/callback',
returnPath: '/isolated'
});
billingSvc.updatePaymentInfo(inv.id, {
gateway: 'xendit',
order_id: result.order_id,
link: result.link,
reference: result.reference,
payload: result.payload
});
return res.json({
success: true,
gateway: 'xendit',
gatewayName: 'Xendit Invoice',
invoiceId: inv.id,
amount: inv.amount,
checkoutUrl: result.link,
periodText,
customerName: cust.name,
helpText: 'Klik tombol bayar untuk membuka halaman pembayaran resmi Xendit.'
});
}
return res.status(400).json({ success: false, message: 'Gateway ' + gateway + ' belum didukung untuk Quick QRIS.' });
} catch (err) {
logger.error(`[QuickQRIS] Error: ${err.message}`);
res.status(500).json({ success: false, message: err.message || 'Gagal memproses pembayaran QRIS' });
}
});
// HELPER FUNCTION: Get active payment channels
function getActivePaymentChannelsForIsolated(settings) {
const channels = [];
// QRIS Static
if (settings.qris_static_enabled && settings.qris_static_payload) {
channels.push({
code: 'QRIS_STATIC',
name: '🟦 QRIS Statis (Instant)',
enabled: true
});
}
// Tripay
if (settings.tripay_enabled && settings.tripay_api_key) {
channels.push({
code: 'TRIPAY',
name: '💳 Transfer Bank / E-Wallet (Tripay)',
enabled: true
});
}
// Midtrans
if (settings.midtrans_enabled && settings.midtrans_server_key) {
channels.push({
code: 'MIDTRANS',
name: '💳 Midtrans Snap',
enabled: true
});
}
// Xendit
if (settings.xendit_enabled && settings.xendit_api_key) {
channels.push({
code: 'XENDIT',
name: '💳 Xendit',
enabled: true
});
}
return channels.length > 0 ? channels : [
{ code: 'QRIS_STATIC', name: '🟦 QRIS Statis', enabled: false }
];
}
// Tambahkan view engine dan static
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
function buildPwaManifest(scope, startUrl, desc, orientation) {
const name = getSetting('company_header', 'OPEN-ISP');
const _lv = getSetting('logo_version', ''); const q = _lv ? ('?v=' + _lv) : '';
return {
id: scope,
lang: 'id',
name: name,
short_name: name,
description: desc || ('Portal pelanggan ' + name + ' \u2014 tagihan, pembayaran, dan pengaturan WiFi.'),
start_url: startUrl,
scope: scope,
display: 'standalone',
orientation: orientation,
background_color: '#0A0D14',
theme_color: '#0A0D14',
icons: [
{ src: '/img/icons/icon-192.png' + q, sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/img/icons/icon-512.png' + q, sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/img/icons/icon-maskable-192.png' + q, sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: '/img/icons/icon-maskable-512.png' + q, sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
};
}
// version.txt — mekanisme update PWA (selalu no-store)
app.get('/version.txt', (req, res) => {
res.type('text/plain; charset=utf-8');
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
const billing = String(getSetting('app_version', '0.5.1') || '0.5.1');
const nameV = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime');
const pwaV = String(getSetting('pwa_version', '2.0') || '2.0');
const stUrl = String(getSetting('speedtest_url', '') || '');
const stMode = String(getSetting('speedtest_mode', 'iframe') || 'iframe');
res.send('billing=' + billing + '\npwa=' + pwaV + '\nname=' + nameV + '\nspeedtest=' + stUrl + '\nmode=' + stMode + '\n');
});
app.get('/manifest.webmanifest', (req, res) => {
res.type('application/manifest+json');
res.send(buildPwaManifest('/customer/', '/customer/login?source=pwa', null, 'portrait'));
});
app.get('/manifest-landing.webmanifest', (req, res) => {
// Disamakan dengan /manifest.webmanifest (id & scope = /customer/) agar identitas
// aplikasi terpasang KONSISTEN dari halaman mana pun — mencegah Chrome menganggap
// app "berbeda" dan memunculkan notifikasi "Tap to copy the URL for this app".
res.type('application/manifest+json');
res.send(buildPwaManifest('/customer/', '/customer/login?source=pwa', 'Internet fiber optic cepat, stabil, dan tanpa batas untuk rumah dan usaha.', 'portrait'));
});
// Service Worker selalu di-revalidate agar update (ikon/branding) cepat diterapkan
app.get('/sw.js', (req, res) => {
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
res.type('application/javascript');
res.sendFile(path.join(__dirname, 'public', 'sw.js'));
});
app.get('/admin/manifest.webmanifest', (req, res) => {
res.type('application/manifest+json');
res.send({
name: 'Admin Billing',
short_name: 'Admin',
start_url: '/admin/settings?source=pwa',
scope: '/admin/',
display: 'standalone',
orientation: 'portrait',
background_color: '#0f172a',
theme_color: '#0f172a',
icons: [
{ src: '/img/pwa-icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' },
{ src: '/img/logo.png', sizes: '2000x545', type: 'image/png', purpose: 'any' }
]
});
});
// ── GOWA Inbound Webhook: pesan masuk → bot command / live chat ──
app.post('/api/webhook/gowa', async (req, res) => {
try {
const sm = require('./config/settingsManager');
const raw = (typeof req.rawBody === 'string' && req.rawBody) ? req.rawBody : (req.body ? JSON.stringify(req.body) : '');
const secret = String(sm.getSetting('gowa_webhook_secret', 'secret') || '');
const sig = String(req.headers['x-hub-signature-256'] || '');
if (secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(raw).digest('hex');
if (sig !== expected) {
logger.warn('[GOWA webhook] signature tidak cocok — ditolak');
return res.status(401).send('Unauthorized');
}
}
let data = {};
try { data = JSON.parse(raw); } catch (e) { data = (req.body && typeof req.body === 'object') ? req.body : {}; }
if (String(data.event || '') !== 'message') return res.status(200).send('OK');
const p = data.payload || {};
if (p.is_from_me) return res.status(200).send('OK');
const fromRaw = String(p.from || p.chat_id || '');
if (!fromRaw || fromRaw.indexOf('@g.us') !== -1) return res.status(200).send('OK');
const senderPhone = fromRaw.split('@')[0].split(':')[0].replace(/\D/g, '');
const text = String(p.body || '').trim();
if (!senderPhone || senderPhone.length < 8 || !text) return res.status(200).send('OK');
// balas cepat ke GOWA, proses di latar belakang
res.status(200).send('OK');
setTimeout(async () => {
try {
try {
const db = require('./config/database');
let custId = null, custName = null;
const cust = db.prepare('SELECT id, name FROM customers WHERE phone LIKE ? OR phone LIKE ?').get('%' + senderPhone.slice(-8) + '%', '%' + senderPhone + '%');
if (cust) { custId = cust.id; custName = cust.name; }
db.prepare("INSERT INTO wa_chat_messages (direction, gateway, sender_phone, recipient_phone, customer_id, customer_name, message_text, status, meta_message_id) VALUES ('inbound','gowa',?,?,?,?,?,?,?)")
.run(senderPhone, 'gowa_bot', custId, custName, text, 'received', String(p.id || ''));
} catch (e) {}
// ── WA = HANYA NOTIFIKASI (keluar) ─────────────────────────────────
// Interaksi/balasan bot DIPINDAH ke Telegram. Pesan masuk tetap dicatat
// (di atas) agar terlihat di Live Chat, TANPA balasan otomatis.
return;
} catch (e) { logger.error('[GOWA webhook] proses error: ' + e.message); }
}, 0);
return;
} catch (e) {
logger.error('[GOWA webhook] error: ' + e.message);
return res.status(200).send('OK');
}
});
// ══ KEAMANAN: file privat (bukti bayar & foto tiket) TIDAK boleh publik ══
app.use(['/uploads/payment_proofs', '/uploads/tickets'], (req, res) => res.status(404).end());
const _PRIVATE_MEDIA = { 'payment-proof': 'payment_proofs', 'ticket': 'tickets' };
app.get('/admin/media/:kind/:file', (req, res) => {
const dir2 = _PRIVATE_MEDIA[req.params.kind];
if (!dir2) return res.status(404).end();
const s2 = req.session || {};
if (!(s2.isAdmin || s2.isTechnician || s2.isCollector || s2.isCashier)) return res.status(401).end();
const file = path.basename(String(req.params.file || ''));
if (!file || !/^[A-Za-z0-9._-]+$/.test(file)) return res.status(400).end();
const abs = path.join(__dirname, 'public', 'uploads', dir2, file);
if (!fs.existsSync(abs)) return res.status(404).end();
res.setHeader('Cache-Control', 'private, no-store');
res.sendFile(abs);
});
app.use(express.static(path.join(__dirname, 'public')));
// Route: Kebijakan Privasi (Privacy Policy) untuk Google Play Store
app.get(['/privacy-policy', '/privacy', '/kebijakan-privasi', '/privacy.html'], (req, res) => {
const { getSetting } = require('./config/settingsManager');
const company = getSetting('company_header', 'OPEN-ISP');
res.render('privacy', {
company,
isLoggedIn: false
});
});
// Route: Syarat & Ketentuan (Terms of Service / TOS) untuk Google Play Store & Pelanggan
app.get(['/terms-of-service', '/terms', '/tos', '/syarat-ketentuan', '/terms.html'], (req, res) => {
const { getSetting, getSettings } = require('./config/settingsManager');
const company = getSetting('company_header', 'OPEN-ISP');
const settings = getSettings ? getSettings() : {};
res.render('tos', {
company,
settings,
isLoggedIn: false
});
});
// Route download APK Android Pelanggan & Admin
app.get(['/download/app', '/download/apk', '/downloads/billing-rtrw.apk', '/download/billing-rtrw.apk', '/downloads/AlijayaCustomer.apk', '/download/AlijayaCustomer.apk'], (req, res) => {
const candidates = [
path.join(__dirname, 'public', 'downloads', 'billing-rtrw.apk'),
path.join(__dirname, 'billing-rtrw.apk'),
path.join(__dirname, 'public', 'downloads', 'AlijayaCustomer.apk'),
path.join(__dirname, 'AlijayaCustomer.apk')
];
const targetPath = candidates.find(p => fs.existsSync(p));
if (targetPath) {
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
return res.download(targetPath, 'billing-rtrw.apk');
}
return res.status(404).json({
success: false,
message: 'File APK belum di-upload ke server VPS. Silakan upload file billing-rtrw.apk ke folder public/downloads/ di server.'
});
});
app.get('/uploads/qris/:filename', async (req, res) => {
const wantsHtml = () => String(req.get('accept') || '').toLowerCase().includes('text/html');
const sendPretty = (status, title, detail) => {
if (!wantsHtml()) return res.status(status).send(title);
const baseUrl = String(getSetting('app_url', '') || `${req.headers['x-forwarded-proto'] || req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
const loginLink = `${baseUrl}/customer/login`;
res.set('Content-Type', 'text/html; charset=utf-8');
return res.status(status).send(`<!doctype html><html lang="id"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>body{font-family:system-ui,Segoe UI,Arial; margin:0; background:#0b1220; color:#e5e7eb} .wrap{max-width:520px;margin:0 auto;padding:24px} .card{background:#0f172a;border:1px solid rgba(148,163,184,.18);border-radius:14px;padding:18px} h1{font-size:18px;margin:0 0 8px} p{margin:0 0 12px;color:#cbd5e1;line-height:1.45} a{display:inline-block;background:#1d4ed8;color:#fff;text-decoration:none;padding:10px 14px;border-radius:10px}</style></head><body><div class="wrap"><div class="card"><h1>${title}</h1><p>${detail || ''}</p><a href="${loginLink}">Buka Portal Pelanggan</a></div></div></body></html>`);
};
try {
const filename = String(req.params.filename || '');
const safeName = path.basename(filename);
if (!safeName || safeName !== filename) return sendPretty(404, 'QRIS tidak ditemukan', 'Link QRIS tidak valid.');
const filePath = path.join(__dirname, 'public', 'uploads', 'qris', safeName);
try {
await fs.promises.access(filePath, fs.constants.R_OK);
return res.sendFile(filePath);
} catch {}
const settings = getSettingsWithCache();
const payload = normalizeQrisPayload(String(settings?.qris_static_payload || ''));
if (payload) {
const png = await QRCode.toBuffer(payload, { errorCorrectionLevel: 'M', margin: 1, width: 420, type: 'png' });
const img = await Jimp.read(png);
const jpg = await img.getBuffer('image/jpeg');
res.set('Content-Type', 'image/jpeg');
res.set('Cache-Control', 'no-store');
return res.status(200).send(jpg);
}
const url = String(settings?.qris_static_qr_url || '').trim();
if (url && !url.endsWith(`/uploads/qris/${safeName}`)) return res.redirect(url);
return sendPretty(404, 'QRIS tidak ditemukan', 'Gambar QRIS upload tidak tersedia. Silakan gunakan link QRIS terbaru dari portal pelanggan.');
} catch {
return sendPretty(404, 'QRIS tidak ditemukan', 'Gagal memuat QRIS.');
}
});
app.get('/qris/static.jpg', async (req, res) => {
const wantsHtml = () => String(req.get('accept') || '').toLowerCase().includes('text/html');
const sendPretty = (status, title, detail) => {
if (!wantsHtml()) return res.status(status).send(title);
const baseUrl = String(getSetting('app_url', '') || `${req.headers['x-forwarded-proto'] || req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
const loginLink = `${baseUrl}/customer/login`;
res.set('Content-Type', 'text/html; charset=utf-8');
return res.status(status).send(`<!doctype html><html lang="id"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>body{font-family:system-ui,Segoe UI,Arial; margin:0; background:#0b1220; color:#e5e7eb} .wrap{max-width:520px;margin:0 auto;padding:24px} .card{background:#0f172a;border:1px solid rgba(148,163,184,.18);border-radius:14px;padding:18px} h1{font-size:18px;margin:0 0 8px} p{margin:0 0 12px;color:#cbd5e1;line-height:1.45} a{display:inline-block;background:#1d4ed8;color:#fff;text-decoration:none;padding:10px 14px;border-radius:10px}</style></head><body><div class="wrap"><div class="card"><h1>${title}</h1><p>${detail || ''}</p><a href="${loginLink}">Buka Portal Pelanggan</a></div></div></body></html>`);
};
try {
const amount = Math.max(0, Math.floor(Number(req.query.amount || 0) || 0));
const settings = getSettingsWithCache();
const qrisUtil = require('./utils/qrisUtil');
let payload = qrisUtil.normalizeQrisPayload(String(settings?.qris_static_payload || ''));
// If payload not in settings, try decode from uploaded QR file
if (!payload && settings?.qris_static_qr_url) {
const url = String(settings.qris_static_qr_url);
const match = url.match(/^\/uploads\/qris\/([^/?#]+)$/i);
if (match && match[1]) {
const safeName = path.basename(match[1]);
const filePath = path.join(__dirname, 'public', 'uploads', 'qris', safeName);
try {
const buf = await fs.promises.readFile(filePath);
payload = await qrisUtil.decodeQrisPayloadFromBuffer(buf);
} catch {}
}
}
if (payload) {
if (amount > 0) {
const jpg = await qrisUtil.buildDynamicQrisJpgBuffer(payload, amount);
res.set('Content-Type', 'image/jpeg');
res.set('Cache-Control', 'no-store');
return res.status(200).send(jpg);
} else {
const png = await QRCode.toBuffer(payload, { errorCorrectionLevel: 'M', margin: 1, width: 420, type: 'png' });
const img = await Jimp.read(png);
const jpg = await img.getBuffer('image/jpeg');
res.set('Content-Type', 'image/jpeg');
res.set('Cache-Control', 'no-store');
return res.status(200).send(jpg);
}
}
// Fallback if static image file exists
const url = String(settings?.qris_static_qr_url || '').trim();
if (url) {
const match = url.match(/^\/uploads\/qris\/([^/?#]+)$/i);
if (match && match[1]) {
const safeName = path.basename(match[1]);
const filePath = path.join(__dirname, 'public', 'uploads', 'qris', safeName);
try {
await fs.promises.access(filePath, fs.constants.R_OK);
return res.sendFile(filePath);
} catch {}
}
return res.redirect(url);
}
return sendPretty(404, 'QRIS tidak ditemukan', 'QRIS belum diatur oleh admin atau payload QRIS tidak valid.');
} catch (e) {
logger.error(`[QRIS /qris/static.jpg] Error: ${e.message}`);
return sendPretty(404, 'QRIS tidak ditemukan', 'Gagal memproses QRIS: ' + e.message);
}
});
app.get('/broadcast', (req, res) => {
res.redirect('/admin/whatsapp/broadcast');
});
// Helper: Ensure unique nominal for donation order
function ensureDonationOrderQrisUnique(baseAmount, donorPhone, donorName, notes) {
const base = Math.max(1000, Math.floor(Number(baseAmount) || 50000));
let chosenCode = 0;
let chosenAmount = 0;
for (let code = 1; code <= 999; code++) {
const candidate = base + code;
const inv = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? LIMIT 1').get('unpaid', candidate);
if (inv && inv.id) continue;
const ord = db.prepare('SELECT id FROM public_voucher_orders WHERE status=? AND qris_amount_unique=? LIMIT 1').get('pending', candidate);
if (ord && ord.id) continue;
const don = db.prepare('SELECT id FROM public_donation_orders WHERE status=? AND qris_amount_unique=? LIMIT 1').get('pending', candidate);
if (don && don.id) continue;
chosenCode = code;
chosenAmount = candidate;
break;
}
if (!chosenAmount) {
chosenCode = Math.floor(1 + Math.random() * 998);
chosenAmount = base + chosenCode;
}
const ins = db.prepare(`
INSERT INTO public_donation_orders (donor_name, donor_phone, amount, qris_amount_unique, qris_unique_code, notes, status, activation_code)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)
`).run(donorName || 'Hamba Allah', donorPhone, base, chosenAmount, chosenCode, notes || '', getSetting('activation_code', ''));
return {
orderId: ins.lastInsertRowid,
uniqueCode: chosenCode,
amountUnique: chosenAmount,
baseAmount: base
};
}
// Halaman Publik Donasi QRIS Statis
app.get(['/donasi', '/donate', '/public/donasi'], (req, res) => {
const settings = getSettingsWithCache();
res.render('donate', {
settings,
company: settings.company_header || 'OPEN-ISP',
lang: req.session?.lang || 'id',
t: res.locals.t || ((k, d) => d)
});
});
// Endpoint Buat Pesanan Donasi dengan Kode Unik
app.post(['/donasi/create', '/api/donasi/create'], (req, res) => {
try {
const { name, phone, amount, notes } = req.body || {};
const donorName = String(name || 'Hamba Allah').trim();
const donorPhone = String(phone || '').trim();
const baseAmount = Math.max(1000, parseInt(amount, 10) || 50000);
const donorNotes = String(notes || '').trim();
if (!donorPhone || donorPhone.length < 8) {
return res.status(400).json({ success: false, message: 'Nomor WhatsApp wajib diisi (minimal 8 digit).' });
}
const result = ensureDonationOrderQrisUnique(baseAmount, donorPhone, donorName, donorNotes);
return res.json({
success: true,
orderId: result.orderId,
uniqueCode: result.uniqueCode,
amountUnique: result.amountUnique,
baseAmount: result.baseAmount,
qrisUrl: `/qris/static.jpg?amount=${result.amountUnique}`
});
} catch (e) {
logger.error(`[Donasi Create] Error: ${e.message}`);
return res.status(500).json({ success: false, message: e.message });
}
});
// Endpoint Status Donasi (Real-time Polling)
app.get(['/donasi/status/:orderId', '/api/donasi/status/:orderId'], (req, res) => {
try {
const orderId = Number(req.params.orderId || 0);
const order = selectDonationOrderById.get(orderId);
if (!order) return res.status(404).json({ success: false, message: 'Order donasi tidak ditemukan' });
return res.json({
success: true,
orderId: order.id,
status: order.status,
paid_at: order.paid_at || null,
activationCode: order.status === 'paid' ? (order.activation_code || getSetting('activation_code', '')) : null,
donorName: order.donor_name,
amountUnique: order.qris_amount_unique,
waSent: Boolean(order.wa_sent)
});
} catch (e) {
return res.status(500).json({ success: false, message: e.message });
}
});
// Endpoint Konfirmasi Donasi & Fallback Kirim Kode Aktivasi
app.post(['/donasi/confirm', '/api/donasi/confirm'], async (req, res) => {
try {
const { orderId, name, phone, amount, notes } = req.body || {};
let donId = Number(orderId || 0);
let ord = donId > 0 ? selectDonationOrderById.get(donId) : null;
if (!ord) {
const donorName = String(name || 'Hamba Allah').trim();
const donorPhone = String(phone || '').trim();
const baseAmount = Math.max(1000, parseInt(amount, 10) || 50000);
if (!donorPhone || donorPhone.length < 8) {
return res.status(400).json({ success: false, message: 'Nomor WhatsApp tidak valid' });
}
const created = ensureDonationOrderQrisUnique(baseAmount, donorPhone, donorName, notes);
donId = created.orderId;
ord = selectDonationOrderById.get(donId);
}
markDonationPaid.run(null, donId);
const fulfillRes = await fulfillDonationOrder(getSettingsWithCache(), donId);
return res.json({
success: true,
orderId: donId,
activationCode: (ord.activation_code || getSetting('activation_code', '')),
donorName: ord.donor_name,
donorAmount: ord.qris_amount_unique || ord.amount,
waSent: fulfillRes.ok,
waError: fulfillRes.error || null
});
} catch (e) {
logger.error(`[Donasi] Error processing donation confirmation: ${e.message}`);
return res.status(500).json({ success: false, message: e.message });
}
});
// Mount built-in ACS server endpoint (TR-069)
const acsServerService = require('./services/acsServerService');
app.post('/acs', express.raw({ type: ['text/xml', 'application/soap+xml', 'application/xml', 'text/plain'], limit: '2mb' }), acsServerService.handleCwmpRequest);
// Landing page untuk scan QR stiker modem ONU pelanggan
app.get('/app/connect', (req, res) => {
const settings = getSettingsWithCache();
const cid = String(req.query.cid || '').trim();
const serverUrl = `${req.protocol}://${req.get('host')}`;
const companyName = settings.company_header || 'ALIJAYA DIGITAL NETWORK';
const companyPhone = settings.company_phone || '';
res.render('app_connect', {
companyName,
companyPhone,
serverUrl,
cid,
settings
});
});
// Mount customer portal
const customerPortal = require('./routes/customerPortal');
app.use('/customer', customerPortal);
// Mount customer REST API for Android Mobile App
const customerAPI = require('./routes/customerAPI');
app.use('/api/customer', customerAPI);
// ── OPEN-ISP admin theme overlay (non-invasive) ──────────────────────────
// Menyisipkan <link admin-theme.css> ke tiap halaman HTML admin TANPA
// mengubah view/partial manapun. Overlay dimuat setelah admin.css.
app.use('/admin', (req, res, next) => {
const originalSend = res.send.bind(res);
res.send = function (body) {
try {
const ct = res.get && res.get('Content-Type');
if (
typeof body === 'string' &&
body.indexOf('<html') !== -1 &&
body.indexOf('/css/admin.css') !== -1 &&
body.indexOf('</head>') !== -1 &&
body.indexOf('/css/admin-theme.css') === -1 &&
body.indexOf('no-admin-theme') === -1
) {
body = body.replace('</head>', ' <link rel="stylesheet" href="/css/admin-theme.css?v=8">\n <script src="/js/admin-theme-sync.js?v=7"></script>\n <script src="/js/admin-sw-cleanup.js" defer></script>\n <script src="/js/admin-toast.js?v=2"></script>\n <script src="/js/admin-search.js?v=1"></script>\n</head>');
}
} catch (e) {}
return originalSend(body);
};
next();
});
// Badge mode status layanan di topbar admin (injeksi non-invasif, tanpa ubah view)
app.use('/admin', (req, res, next) => {
const originalSend = res.send.bind(res);
res.send = function (body) {
try {
if (
typeof body === 'string' &&
body.indexOf('<html') !== -1 &&
body.indexOf('</head>') !== -1 &&
body.indexOf('/js/service-notice-badge.js') === -1
) {
const noticeJson = JSON.stringify(noticeSvc.getServiceNotice()).split('<').join('\\u003c');
body = body.replace('</head>', ' <link rel="stylesheet" href="/css/service-notice-badge.css?v=3">\n <script>window.__serviceNotice=' + noticeJson + ';</script>\n <script src="/js/service-notice-badge.js?v=2" defer></script>\n</head>');
}
} catch (e) {}
return originalSend(body);
};
next();
});
// Mount admin portal
const adminPortal = require('./routes/adminPortal');
app.use('/admin', adminPortal);
// Mount tech portal
const techPortal = require('./routes/techPortal');
app.use('/tech', techPortal);
// Mount agent portal
// Mount collector portal
const collectorPortal = require('./routes/collectorPortal');
app.use('/collector', collectorPortal);
// Fungsi untuk memulai server dengan penanganan port yang sudah digunakan
function startServer(portToUse) {
logger.info(`Mencoba memulai server pada port ${portToUse}...`);
// Coba port alternatif jika port utama tidak tersedia
try {
const server = app.listen(portToUse, () => {
logger.info(`Server berhasil berjalan pada port ${portToUse}`);
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
// Update global.appSettings.port dengan port yang berhasil digunakan
global.appSettings.port = portToUse.toString();
// Voucher cache warmer dinonaktifkan — halaman voucher sekarang direct query ke MikroTik
// const voucherCacheWarmer = require('./services/voucherCacheWarmer');
// voucherCacheWarmer.startCacheWarming();
}).on('error', (err) => {
if (err.code === 'EADDRINUSE') {
logger.warn(`PERINGATAN: Port ${portToUse} sudah digunakan, mencoba port alternatif...`);
// Coba port alternatif (port + 1000)
const alternativePort = portToUse + 1000;
logger.info(`Mencoba port alternatif: ${alternativePort}`);
// Buat server baru dengan port alternatif
const alternativeServer = app.listen(alternativePort, () => {
logger.info(`Server berhasil berjalan pada port alternatif ${alternativePort}`);
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
// Update global.appSettings.port dengan port yang berhasil digunakan
global.appSettings.port = alternativePort.toString();
// Voucher cache warmer dinonaktifkan — halaman voucher sekarang direct query ke MikroTik
// const voucherCacheWarmer = require('./services/voucherCacheWarmer');
// voucherCacheWarmer.startCacheWarming();
}).on('error', (altErr) => {
logger.error(`ERROR: Gagal memulai server pada port alternatif ${alternativePort}:`, altErr.message);
process.exit(1);
});
} else {
logger.error('Error starting server:', err);
process.exit(1);
}
});
} catch (error) {
logger.error(`Terjadi kesalahan saat memulai server:`, error);
process.exit(1);
}
}
// Mulai server dengan port dari settings.json
const port = global.appSettings.port;
logger.info(`Attempting to start server on configured port: ${port}`);
// Mulai server dengan port dari konfigurasi
startServer(port);
if (getSetting('whatsapp_enabled', false)) {
const gwType = getSetting('wa_gateway_type', 'baileys');
if (gwType === 'baileys') {
import('./services/whatsappBot.mjs')
.then((mod) => mod.startWhatsAppBot())
.catch((err) => logger.error('Gagal memulai WhatsApp bot (Baileys):', err));
}
}
if (getSetting('telegram_enabled', false)) {
const { initTelegram } = require('./services/telegramBot');
initTelegram();
}
// Mulai cron jobs (generate tagihan otomatis, dll)
const { startCronJobs } = require('./services/cronService');
startCronJobs();
// Mulai auto backup
scheduleAutoBackup();
// Inisialisasi RADIUS Server jika diaktifkan di settings
const radiusSvc = require('./services/radiusServerService');
if (getSetting('radius_enabled', '0') === '1') {
radiusSvc.start();
}
// Error handling middleware (harus di akhir setelah semua routes)
app.use(notFoundHandler);
app.use(errorHandler);
// Export app untuk testing
module.exports = app;