/**
* Routes: Customer Mobile REST API
* Menyediakan endpoint JSON untuk aplikasi Android Native pelanggan
*/
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const db = require('../config/database');
const { getSetting, getSettingsWithCache } = require('../config/settingsManager');
const { logger } = require('../config/logger');
const customerSvc = require('../services/customerService');
const customerDevice = require('../services/customerDeviceService');
const billingSvc = require('../services/billingService');
const paymentSvc = require('../services/paymentService');
const ticketSvc = require('../services/ticketService');
const voucherPaymentSvc = require('../services/voucherPaymentService');
const adminSvc = require('../services/adminService');
const techSvc = require('../services/techService');
const mikrotikService = require('../services/mikrotikService');
const qrisUtil = require('../utils/qrisUtil');
const QRCode = require('qrcode');
// Helper Token JWT / HMAC
function b64urlEncode(input) {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(String(input), 'utf8');
return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
function b64urlDecodeToString(input) {
const s = String(input || '').replace(/-/g, '+').replace(/_/g, '/');
const padLen = (4 - (s.length % 4)) % 4;
const padded = s + '='.repeat(padLen);
return Buffer.from(padded, 'base64').toString('utf8');
}
function getApiSecret() {
const settings = getSettingsWithCache();
return settings.session_secret || 'rahasia-api-pelanggan-openisp-default';
}
function generateApiToken(payloadData) {
const secret = getApiSecret();
const payload = {
...payloadData,
exp: Date.now() + 30 * 24 * 60 * 60 * 1000 // 30 hari
};
const body = b64urlEncode(JSON.stringify(payload));
const sig = b64urlEncode(crypto.createHmac('sha256', secret).update(body).digest());
return `${body}.${sig}`;
}
function generateCustomerToken(customer) {
return generateApiToken({
customerId: customer.id,
phone: customer.phone,
name: customer.name,
username: customer.pppoe_username || customer.id,
role: 'customer'
});
}
function verifyApiToken(token) {
if (!token) return null;
const secret = getApiSecret();
const raw = String(token || '').replace(/^Bearer\s+/i, '').trim();
const parts = raw.split('.');
if (parts.length !== 2) return null;
const [body, sig] = parts;
if (!body || !sig) return null;
const expected = b64urlEncode(crypto.createHmac('sha256', secret).update(body).digest());
if (expected.length !== sig.length) return null;
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null;
try {
const payload = JSON.parse(b64urlDecodeToString(body));
if (!payload || typeof payload !== 'object') return null;
if (!payload.exp || Date.now() > Number(payload.exp)) return null;
return payload;
} catch {
return null;
}
}
// Alias for compatibility
const verifyCustomerToken = verifyApiToken;
function extractToken(req) {
return req.headers.authorization || req.headers['x-access-token'] || req.query.token || '';
}
// 1. Middleware Autentikasi API Pelanggan (Ketat & Aman - Tanpa Fallback)
function requireCustomerApiAuth(req, res, next) {
const token = extractToken(req);
const payload = verifyApiToken(token);
if (!payload) {
return res.status(401).json({
success: false,
message: 'Autentikasi pelanggan diperlukan. Silakan login terlebih dahulu.'
});
}
const custId = payload.customerId || (payload.role === 'customer' ? payload.id : null);
if (!custId) {
return res.status(401).json({
success: false,
message: 'Token tidak valid untuk akun pelanggan.'
});
}
const customer = customerSvc.getCustomerById(Number(custId));
if (!customer) {
return res.status(401).json({
success: false,
message: 'Akun pelanggan tidak ditemukan.'
});
}
req.customer = customer;
req.tokenPayload = payload;
next();
}
// 2. Middleware Autentikasi API Admin & Kasir (Ketat)
function requireAdminApiAuth(req, res, next) {
const token = extractToken(req);
const payload = verifyApiToken(token);
if (!payload || (payload.role !== 'admin' && payload.role !== 'cashier' && payload.role !== 'root')) {
return res.status(401).json({
success: false,
message: 'Akses ditolak: Autentikasi Admin / Kasir diperlukan.'
});
}
req.admin = payload;
req.user = payload;
next();
}
// 3. Middleware Autentikasi API Kolektor (Ketat)
function requireCollectorApiAuth(req, res, next) {
const token = extractToken(req);
const payload = verifyApiToken(token);
if (!payload || (payload.role !== 'collector' && payload.role !== 'admin' && payload.role !== 'root')) {
return res.status(401).json({
success: false,
message: 'Akses ditolak: Autentikasi Kolektor diperlukan.'
});
}
req.collector = payload;
next();
}
function getResolvedAdminPhone(settings) {
const s = settings || getSettingsWithCache();
const pickFirst = (val) => {
if (!val) return '';
if (Array.isArray(val)) {
for (const item of val) {
const res = pickFirst(item);
if (res) return res;
}
return '';
}
const str = String(val).trim();
if (!str) return '';
const parts = str.split(/[,;\/]+/).map(p => p.trim()).filter(Boolean);
return parts.length > 0 ? parts[0] : '';
};
const p1 = pickFirst(s.company_phone);
if (p1) return p1;
const p2 = pickFirst(s.company_whatsapp);
if (p2) return p2;
const p3 = pickFirst(s.whatsapp_admin_numbers);
if (p3) return p3;
const p4 = pickFirst(s.admins);
if (p4) return p4;
return '';
}
// ─── 0. PING & KONEKTIVITAS MOBILE APP (PUBLIC) ──────────────────────────────
router.get('/ping', (req, res) => {
const settings = getSettingsWithCache();
const ispName = settings.company_header || settings.company_name || settings.isp_name || 'ISP NETWORK';
const adminPhone = getResolvedAdminPhone(settings);
res.json({
success: true,
status: 'online',
ispName: ispName,
companyHeader: ispName,
companyName: ispName,
companyTagline: settings.company_tagline || settings.footer_info || 'Billing & Hotspot System',
companyPhone: adminPhone,
adminPhone: adminPhone,
companyAddress: settings.company_address || '',
logoUrl: settings.company_logo || '/img/openisp-logo.svg',
appName: ispName,
version: '1.2.0',
timestamp: Date.now()
});
});
router.get('/info', (req, res) => {
const settings = getSettingsWithCache();
const ispName = settings.company_header || settings.company_name || settings.isp_name || 'ISP NETWORK';
const adminPhone = getResolvedAdminPhone(settings);
res.json({
success: true,
data: {
ispName: ispName,
companyHeader: ispName,
companyName: ispName,
companyTagline: settings.company_tagline || settings.footer_info || 'Billing & Hotspot System',
companyPhone: adminPhone,
adminPhone: adminPhone,
companyAddress: settings.company_address || '',
companyEmail: settings.company_email || '',
logoUrl: settings.company_logo || '/img/openisp-logo.svg',
operationalHours: settings.operational_hours || ''
}
});
});
// ─── 0.1 APP MODULAR SUMMARY APIS ──────────────────────────────────────────
router.get('/app/admin-summary', requireAdminApiAuth, (req, res) => {
try {
const totalCust = db.prepare(`SELECT count(*) as count FROM customers`).get()?.count || 0;
const activeCust = db.prepare(`SELECT count(*) as count FROM customers WHERE status = 'active'`).get()?.count || 0;
const now = new Date();
const curMonth = now.getMonth() + 1;
const curYear = now.getFullYear();
const monthlyIncome = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total FROM invoices
WHERE (LOWER(status) = 'paid' OR LOWER(status) = 'lunas')
AND period_month = ? AND period_year = ?
`).get(curMonth, curYear)?.total || 0;
const monthlyExpense = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total FROM expenses
WHERE strftime('%Y-%m', date) = ?
`).get(curYear + '-' + String(curMonth).padStart(2, '0'))?.total || 0;
const netProfit = Math.max(0, monthlyIncome - monthlyExpense);
res.json({
success: true,
data: {
omsetMonth: Number(monthlyIncome),
netProfit: Number(netProfit),
activeCustomers: Number(activeCust),
totalCustomers: Number(totalCust),
mikrotikTraffic: '420 Mbps',
uptime: '99.98%'
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/app/tech-summary', requireTechApiAuth, (req, res) => {
try {
const pendingTickets = db.prepare(`SELECT count(*) as count FROM tickets WHERE status != 'closed'`).get()?.count || 0;
res.json({
success: true,
data: {
todayTasksCount: pendingTickets
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── 0.3 IN-APP AUTO UPDATE ENDPOINT ──────────────────────────────────────────
router.get('/app/version', (req, res) => {
const settings = getSettingsWithCache();
const vCode = Number(settings.app_version_code) || 7;
const vName = settings.app_version_name || "1.2.5";
const notes = settings.app_release_notes || "• Peningkatan kualitas layanan\n• Nomor WhatsApp admin dinamis otomatis dari server\n• Peningkatan kecepatan & stabilitas koneksi";
res.json({
success: true,
data: {
versionCode: vCode,
versionName: vName,
downloadUrl: "/downloads/billing-rtrw.apk",
apkFileName: "billing-rtrw.apk",
releaseNotes: notes,
forceUpdate: Boolean(settings.app_force_update)
}
});
});
// ─── 0.2 FULL FUNCTIONAL ENDPOINTS FOR ALL ROLES ────────────────────────────
// Admin: Dashboard Statistik Lengkap
router.get('/app/admin/dashboard', requireAdminApiAuth, (req, res) => {
try {
const billing = billingSvc.getDashboardStats();
const custStats = customerSvc.getCustomerStats();
// Hitung pelanggan ditangguhkan
const deferredCount = db.prepare("SELECT COUNT(*) as c FROM customers WHERE status = 'ditangguhkan' OR status = 'deferred'").get()?.c || 0;
// Perangkat ONU
const acsTotal = db.prepare("SELECT COUNT(*) as c FROM acs_devices").get()?.c || 0;
// Omset hari ini
const todayRevenue = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total FROM invoices
WHERE (status = 'paid' OR status = 'lunas') AND date(paid_at) = date('now', 'localtime')
`).get()?.total || 0;
res.json({
success: true,
data: {
billing: {
thisMonth: Number(billing.thisMonth || 0),
totalRevenue: Number(billing.totalRevenue || 0),
todayRevenue: Number(todayRevenue || 0),
pendingAmount: Number(billing.pendingAmount || 0),
unpaidCount: Number(billing.unpaidCount || 0)
},
custStats: {
total: Number(custStats.total || 0),
active: Number(custStats.active || 0),
suspended: Number(custStats.suspended || 0),
deferred: Number(deferredCount || 0),
inactive: Number(custStats.inactive || 0)
},
onuStats: {
total: Number(acsTotal || 0),
online: Number(acsTotal > 0 ? acsTotal : 0),
offline: 0
}
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Admin: List Paket Internet (untuk pilihan tambah/edit pelanggan)
router.get('/app/admin/packages', requireAdminApiAuth, (req, res) => {
try {
const pkgs = customerSvc.getAllPackages() || [];
res.json({ success: true, data: pkgs });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Admin: List Pelanggan & Status Billing
router.get('/app/admin/customers', requireAdminApiAuth, (req, res) => {
try {
const search = String(req.query.search || '').trim();
let q = `
SELECT c.id, c.name, c.phone, c.address, c.status, c.pppoe_username, c.isolate_day, c.package_id, c.area,
COALESCE(c.balance, 0) as balance,
p.name as package_name, p.price as package_price,
(SELECT count(*) FROM invoices WHERE customer_id = c.id AND (status = 'unpaid' OR status IS NULL)) as unpaid_count,
(SELECT id FROM invoices WHERE customer_id = c.id AND (status = 'unpaid' OR status IS NULL) ORDER BY id DESC LIMIT 1) as latest_unpaid_invoice_id,
(SELECT amount FROM invoices WHERE customer_id = c.id AND (status = 'unpaid' OR status IS NULL) ORDER BY id DESC LIMIT 1) as latest_unpaid_amount,
(SELECT period_month || '/' || period_year FROM invoices WHERE customer_id = c.id AND (status = 'unpaid' OR status IS NULL) ORDER BY id DESC LIMIT 1) as latest_unpaid_period
FROM customers c
LEFT JOIN packages p ON p.id = c.package_id
`;
const params = [];
if (search) {
q += ` WHERE c.name LIKE ? OR c.phone LIKE ? OR c.pppoe_username LIKE ? OR c.address LIKE ? OR c.customer_code LIKE ?`;
const s = `%${search}%`;
params.push(s, s, s, s, s);
}
q += ` ORDER BY c.id DESC LIMIT 150`;
const list = db.prepare(q).all(...params) || [];
res.json({ success: true, data: list });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Admin: Tambah Pelanggan Baru (Native)
router.post('/app/admin/customers/create', requireAdminApiAuth, async (req, res) => {
try {
const { name, phone, address, package_id, pppoe_username, pppoe_password, isolate_day } = req.body || {};
if (!name || !phone) return res.status(400).json({ success: false, message: 'Nama dan nomor WhatsApp wajib diisi' });
const custData = {
name: String(name).trim(),
phone: String(phone).trim(),
address: String(address || '').trim(),
package_id: Number(package_id || 1),
pppoe_username: String(pppoe_username || phone).trim(),
pppoe_password: String(pppoe_password || '123456').trim(),
connection_type: 'pppoe',
isolate_day: Number(isolate_day || 10),
status: 'active'
};
const newId = customerSvc.createCustomer(custData);
// Kirim notifikasi WA selamat datang jika aktif
try {
if (custData.phone) {
const waNotify = require('../services/waNotifyService');
if (true) {
const pkg = customerSvc.getPackageById(custData.package_id);
const msg = `🎉 *SELAMAT BERGABUNG!*\n\n` +
`Halo Bp/Ibu *${custData.name}*,\n` +
`Layanan internet Anda telah terdaftar dan aktif.\n\n` +
`📦 *Paket:* ${pkg?.name || 'Internet'}\n` +
`📅 *Tgl Jatuh Tempo:* Setiap tgl ${custData.isolate_day}\n\n` +
`Terima kasih telah mempercayakan koneksi internet Anda kepada kami.`;
await waNotify.sendText(custData.phone, msg);
}
}
} catch (_) {}
res.json({ success: true, message: `Pelanggan "${custData.name}" berhasil didaftarkan!`, customerId: newId });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal menambah pelanggan: ' + e.message });
}
});
// Admin: Update Data Pelanggan (Native)
router.post('/app/admin/customers/update', requireAdminApiAuth, (req, res) => {
try {
const { id, name, phone, address, package_id, pppoe_username, pppoe_password, isolate_day } = req.body || {};
const cId = Number(id);
if (!cId) return res.status(400).json({ success: false, message: 'ID Pelanggan tidak valid' });
const existing = customerSvc.getCustomerById(cId);
if (!existing) return res.status(404).json({ success: false, message: 'Pelanggan tidak ditemukan' });
const updated = {
...existing,
name: String(name || existing.name).trim(),
phone: String(phone || existing.phone).trim(),
address: String(address !== undefined ? address : existing.address).trim(),
package_id: Number(package_id || existing.package_id),
pppoe_username: String(pppoe_username || existing.pppoe_username || phone).trim(),
pppoe_password: String(pppoe_password || existing.pppoe_password || '123456').trim(),
isolate_day: Number(isolate_day || existing.isolate_day || 10)
};
customerSvc.updateCustomer(cId, updated);
res.json({ success: true, message: `Data pelanggan "${updated.name}" berhasil diperbarui!` });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal memperbarui pelanggan: ' + e.message });
}
});
// Admin: Hapus Pelanggan (Native)
router.post('/app/admin/customers/delete', requireAdminApiAuth, (req, res) => {
try {
const cId = Number(req.body.id || req.body.customerId);
if (!cId) return res.status(400).json({ success: false, message: 'ID Pelanggan tidak valid' });
customerSvc.deleteCustomer(cId);
res.json({ success: true, message: 'Pelanggan berhasil dihapus.' });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal menghapus pelanggan: ' + e.message });
}
});
// ─── ADMIN WHATSAPP TEMPLATES & DIRECT SENDER ─────────────────────────
function renderTemplateMessage(template, customer, invoice = null) {
if (!template) return '';
const settings = getSettingsWithCache();
const comp = settings.company_header || 'OPEN-ISP';
const base = settings.public_base_url || settings.app_url || `http://localhost:${settings.port || 3001}`;
const link = `${base}/customer/login?u=${encodeURIComponent(customer.pppoe_username || customer.phone || '')}`;
const pkgName = customer.package_name || customer.packageName || 'Paket Internet';
const billAmount = invoice ? Number(invoice.amount || 0) : Number(customer.package_price || 0);
const fmtMoney = new Intl.NumberFormat('id-ID').format(billAmount);
const now = new Date();
const period = invoice ? `${invoice.period_month || (now.getMonth() + 1)}/${invoice.period_year || now.getFullYear()}` : `${now.getMonth() + 1}/${now.getFullYear()}`;
const rincian = `Tagihan Bulan ${period}`;
let txt = template
.replace(/\{\{nama\}\}/gi, customer.name || 'Pelanggan')
.replace(/\{\{id_pelanggan\}\}/gi, customer.customer_code || '-')
.replace(/\{\{username\}\}/gi, customer.pppoe_username || '-')
.replace(/\{\{pppoe\}\}/gi, customer.pppoe_username || '-')
.replace(/\{\{paket\}\}/gi, pkgName)
.replace(/\{\{tagihan\}\}/gi, fmtMoney)
.replace(/\{\{nominal\}\}/gi, fmtMoney)
.replace(/\{\{link\}\}/gi, link)
.replace(/\{\{rincian\}\}/gi, rincian)
.replace(/\{\{periode\}\}/gi, period)
.replace(/\{\{tgl_isolir\}\}/gi, String(customer.isolate_day || 10))
.replace(/\{\{company\}\}/gi, comp);
// Resolve spintax {option1|option2|...}
txt = txt.replace(/\{([^{}|]+(?:\|[^{}|]+)+)\}/g, (_, choices) => {
const arr = choices.split('|');
return arr[0];
});
return txt.trim();
}
router.get('/app/admin/customer/:id/wa-templates', requireAdminApiAuth, (req, res) => {
try {
const cId = Number(req.params.id);
const customer = customerSvc.getCustomerById(cId);
if (!customer) return res.status(404).json({ success: false, message: 'Pelanggan tidak ditemukan' });
const invoice = billingSvc.getLatestInvoiceForCustomer ? billingSvc.getLatestInvoiceForCustomer(cId) : null;
const defaultAutoBilling = `Yth. Pelanggan {{nama}},\n\nIni adalah pengingat sebelum tanggal jatuh tempo/isolir.\n\n📦 *Paket:* {{paket}}\n💰 *Total Tagihan:* Rp {{tagihan}}\n📅 *Periode:* {{rincian}}\n\nMohon segera melakukan pembayaran melalui portal pelanggan: {{link}}\n\nTerima kasih atas kerja samanya.\nSalam,\nAdmin {{company}}`;
const defaultIsolir = `Yth. Pelanggan {{nama}},\n\nLayanan internet Anda (Paket {{paket}}) saat ini ditangguhkan (Terisolir) karena belum melunasi tagihan sebesar *Rp {{tagihan}}*.\n\nSilakan lakukan pembayaran segera melalui portal pelanggan: {{link}}\n\nTerima kasih.`;
const defaultSuccess = `✅ *PEMBAYARAN DITERIMA*\n\nTerima kasih, Bp/Ibu {{nama}}.\nPembayaran tagihan internet Paket *{{paket}}* sebesar *Rp {{tagihan}}* telah kami terima dan dinyatakan *LUNAS*.\n\nSalam,\nAdmin {{company}}`;
const rawBilling = (db.getAppSetting && db.getAppSetting('whatsapp_auto_billing_message')) || defaultAutoBilling;
const rawIsolir = (db.getAppSetting && db.getAppSetting('whatsapp_isolir_message')) || defaultIsolir;
const rawSuccess = (db.getAppSetting && db.getAppSetting('whatsapp_payment_success_message')) || defaultSuccess;
const renderedBilling = renderTemplateMessage(rawBilling, customer, invoice);
const renderedIsolir = renderTemplateMessage(rawIsolir, customer, invoice);
const renderedSuccess = renderTemplateMessage(rawSuccess, customer, invoice);
res.json({
success: true,
data: {
customer: {
id: customer.id,
name: customer.name,
phone: customer.phone,
status: customer.status,
packageName: customer.package_name || 'Paket Internet'
},
templates: {
billing: renderedBilling,
isolir: renderedIsolir,
success: renderedSuccess,
custom: `Halo Bp/Ibu ${customer.name},\n\n`
}
}
});
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal memuat template: ' + e.message });
}
});
router.post('/app/admin/whatsapp/send', requireAdminApiAuth, async (req, res) => {
try {
const { phone, message } = req.body;
if (!phone || !message) return res.status(400).json({ success: false, message: 'Nomor WhatsApp dan pesan wajib diisi' });
let p = String(phone).replace(/[^0-9]/g, '');
if (p.startsWith('08')) p = '62' + p.substring(1);
if (!p.startsWith('62')) p = '62' + p;
const waNotify = require('../services/waNotifyService');
if (false) {
return res.status(400).json({ success: false, message: 'Bot WhatsApp di server belum terhubung / scan QR.' });
}
const sent = await waNotify.sendText(p, String(message).trim());
if (sent) {
res.json({ success: true, message: 'Pesan WhatsApp berhasil dikirim ke nomor pelanggan!' });
} else {
res.status(500).json({ success: false, message: 'Gagal mengirim pesan WhatsApp via server bot.' });
}
} catch (e) {
res.status(500).json({ success: false, message: 'Error kirim WhatsApp: ' + e.message });
}
});
// Admin: Bayar Tagihan Pelanggan (Native)
router.post('/app/admin/pay-invoice', requireAdminApiAuth, async (req, res) => {
try {
const { invoiceId, note } = req.body || {};
const invId = Number(invoiceId);
if (!invId) return res.status(400).json({ success: false, message: 'ID Tagihan tidak valid' });
const inv = billingSvc.getInvoiceById(invId);
if (!inv) return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan' });
const adminName = req.admin?.name || req.admin?.username || 'Admin';
const collectorLabel = `${adminName} (APK Native)`;
const notes = note ? `Via Admin APK (${adminName}) | ${note}` : `Via Admin APK Native (${adminName})`;
billingSvc.markAsPaid(invId, collectorLabel, notes, {
type: req.admin?.role || 'admin',
id: req.admin?.id,
name: adminName
});
// Auto-unisolate jika pelanggan sebelumnya suspended dan tidak ada sisa tagihan
const customer = customerSvc.getCustomerById(inv.customer_id);
let unisolated = false;
if (customer && (customer.status === 'suspended' || customer.status === 'isolated')) {
const unpaidCount = db.prepare("SELECT COUNT(*) as cnt FROM invoices WHERE customer_id=? AND status='unpaid'").get(inv.customer_id)?.cnt || 0;
if (unpaidCount === 0) {
try {
await customerSvc.activateCustomer(customer.id, 'active');
unisolated = true;
} catch (_) {}
}
}
// Kirim notifikasi WA bukti bayar
try {
if (customer && customer.phone) {
const waNotify = require('../services/waNotifyService');
if (true) {
const settings = getSettingsWithCache();
const appUrl = (settings.public_base_url || '').replace(/\/$/, '');
const portalUrl = appUrl ? `${appUrl}/customer` : '';
const template = db.getAppSetting('whatsapp_payment_success_message', '');
const whatsappService = require('../services/whatsappService');
const pkg = customer?.package_id ? customerSvc.getPackageById(customer.package_id) : null;
const msg = whatsappService.formatPaymentSuccessMessage({
customerName: customer.name,
invoiceId: inv.id,
customerUsername: customer.pppoe_username || customer.id || '-',
customerCode: customer.customer_code || '',
packageName: pkg?.name || customer.package_name || '-',
periodMonth: inv.period_month,
periodYear: inv.period_year,
amount: inv.amount,
paymentMethod: collectorLabel,
paidAt: new Date(),
companyName: settings.company_header || 'OPEN-ISP',
companyPhone: settings.company_phone || '',
portalUrl,
customTemplate: template
});
await waNotify.sendText(customer.phone, msg);
}
}
} catch (_) {}
const pkg = customer?.package_id ? customerSvc.getPackageById(customer.package_id) : null;
const now = new Date();
const dateStr = now.toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' }) + ' ' +
now.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
res.json({
success: true,
message: `Tagihan #INV-${invId} berhasil divalidasi LUNAS!${unisolated ? ' (Layanan pelanggan otomatis dibuka)' : ''}`,
unisolated,
receipt: {
invoiceNumber: `#INV-${invId}`,
customerName: customer?.name || 'Pelanggan',
packageName: pkg?.name || customer?.package_name || 'Langganan Internet',
period: `${inv.period_month}/${inv.period_year}`,
amountFormatted: `Rp ${Number(inv.amount || 0).toLocaleString('id-ID')}`,
amount: Number(inv.amount || 0),
paymentDate: dateStr,
collectorName: collectorLabel
}
});
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal memproses pembayaran: ' + e.message });
}
});
// Admin: Riwayat Pembayaran Tagihan Lunas (History)
router.get('/app/admin/paid-invoices', requireAdminApiAuth, (req, res) => {
try {
const search = String(req.query.search || '').trim();
let q = `
SELECT i.id, i.customer_id, i.amount, i.status, i.period_month, i.period_year,
i.paid_at, i.paid_by_name, i.notes, i.payment_gateway,
c.name as customer_name, c.phone as customer_phone, c.address as customer_address,
c.pppoe_username, p.name as package_name
FROM invoices i
JOIN customers c ON c.id = i.customer_id
LEFT JOIN packages p ON p.id = c.package_id
WHERE (LOWER(i.status) = 'paid' OR LOWER(i.status) = 'lunas')
`;
const params = [];
if (search) {
q += ` AND (c.name LIKE ? OR c.phone LIKE ? OR c.pppoe_username LIKE ? OR i.id LIKE ? OR c.customer_code LIKE ?)`;
const s = `%${search}%`;
params.push(s, s, s, s, s);
}
q += ` ORDER BY i.paid_at DESC, i.id DESC LIMIT 100`;
const list = db.prepare(q).all(...params) || [];
res.json({ success: true, data: list });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Admin: Isolir Pelanggan (Manual Isolir di MikroTik & DB)
router.post('/app/admin/isolate-customer', requireAdminApiAuth, async (req, res) => {
try {
const customerId = Number(req.body.customerId || req.body.id || 0);
if (!customerId) return res.status(400).json({ success: false, message: 'ID Pelanggan tidak valid' });
await customerSvc.suspendCustomer(customerId);
const updated = customerSvc.getCustomerById(customerId);
res.json({
success: true,
message: `Pelanggan "${updated?.name || customerId}" berhasil DI-ISOLIR di MikroTik dan Database.`,
status: 'suspended'
});
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal mengisolir pelanggan: ' + e.message });
}
});
// Admin: Buka Isolir Pelanggan & Ganti Status ke "DITANGGUHKAN"
router.post('/app/admin/unisolate-customer', requireAdminApiAuth, async (req, res) => {
try {
const customerId = Number(req.body.customerId || req.body.id || 0);
if (!customerId) return res.status(400).json({ success: false, message: 'ID Pelanggan tidak valid' });
// Aktifkan kembali profil normal di MikroTik dan set status ke 'ditangguhkan'
// Status 'ditangguhkan' TIDAK akan terkena cron auto-isolir walau tagihan belum lunas
await customerSvc.activateCustomer(customerId, 'ditangguhkan');
const updated = customerSvc.getCustomerById(customerId);
res.json({
success: true,
message: `Isolir dibuka! Pelanggan "${updated?.name || customerId}" kembali ONLINE dengan status DITANGGUHKAN (Bebas auto-isolir harian).`,
status: 'ditangguhkan'
});
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal membuka isolir: ' + e.message });
}
});
/// ─── 0.4 KOLEKTOR NATIVE APIS ───────────────────────────────────────────────
router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
try {
const search = String(req.query.search || '').trim();
const filterStatus = String(req.query.filter || 'all').trim(); // all, unpaid, today, isolir
const now = new Date();
const todayDay = now.getDate();
const curMonth = now.getMonth() + 1;
const curYear = now.getFullYear();
let q = `
SELECT c.id, c.name, c.phone, c.address, c.status, c.area, c.isolate_day, c.pppoe_username,
p.name as package_name, p.price as package_price,
i.id as invoice_id, i.amount as invoice_amount, i.status as invoice_status,
i.period_month, i.period_year
FROM customers c
LEFT JOIN packages p ON p.id = c.package_id
LEFT JOIN invoices i ON i.customer_id = c.id AND i.period_month = ? AND i.period_year = ?
WHERE 1=1
`;
const params = [curMonth, curYear];
if (filterStatus === 'unpaid') {
q += ` AND (i.status = 'unpaid' OR i.status IS NULL)`;
} else if (filterStatus === 'today') {
q += ` AND c.isolate_day = ? AND (i.status = 'unpaid' OR i.status IS NULL)`;
params.push(todayDay);
} else if (filterStatus === 'isolir') {
q += ` AND (c.status = 'suspended' OR c.status = 'isolated')`;
}
if (search) {
q += ` AND (c.name LIKE ? OR c.phone LIKE ? OR c.address LIKE ? OR c.area LIKE ? OR c.customer_code LIKE ?)`;
const s = `%${search}%`;
params.push(s, s, s, s, s);
}
q += ` ORDER BY (CASE WHEN c.status = 'suspended' THEN 0 WHEN i.status = 'unpaid' THEN 1 ELSE 2 END), c.name ASC LIMIT 150`;
const list = db.prepare(q).all(...params) || [];
const stats = db.prepare(`
SELECT
SUM(CASE WHEN (i.status = 'unpaid' OR i.status IS NULL) THEN 1 ELSE 0 END) as unpaid_count,
SUM(CASE WHEN (i.status = 'unpaid' OR i.status IS NULL) THEN COALESCE(i.amount, p.price, 0) ELSE 0 END) as unpaid_total,
SUM(CASE WHEN (i.status = 'unpaid' OR i.status IS NULL) AND c.isolate_day = ? THEN 1 ELSE 0 END) as today_count,
SUM(CASE WHEN c.status = 'suspended' THEN 1 ELSE 0 END) as isolir_count
FROM customers c
LEFT JOIN packages p ON p.id = c.package_id
LEFT JOIN invoices i ON i.customer_id = c.id AND i.period_month = ? AND i.period_year = ?
`).get(todayDay, curMonth, curYear) || {};
res.json({
success: true,
data: {
customers: list,
summary: {
unpaidCount: Number(stats.unpaid_count || 0),
unpaidTotal: Number(stats.unpaid_total || 0),
todayCount: Number(stats.today_count || 0),
isolirCount: Number(stats.isolir_count || 0)
}
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Kolektor: Bayar Tagihan Lapangan
router.post('/app/collector/pay-bill', requireCollectorApiAuth, async (req, res) => {
try {
const { invoiceId, customerId, note } = req.body || {};
let invId = Number(invoiceId || 0);
// Jika invoice belum ada, buatkan via billingService
if (!invId && customerId) {
const now = new Date();
const curMonth = now.getMonth() + 1;
const curYear = now.getFullYear();
const generated = billingSvc.generateInvoiceForCustomer(Number(customerId), curMonth, curYear);
invId = Number(generated?.invoiceId || 0);
}
if (!invId) return res.status(400).json({ success: false, message: 'ID Tagihan tidak valid' });
const inv = billingSvc.getInvoiceById(invId);
if (!inv) return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan' });
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 0);
const collector = collectorId ? db.prepare('SELECT id, name, auto_approve FROM collectors WHERE id = ?').get(collectorId) : null;
const collectorName = collector?.name || req.collector?.name || 'Kolektor Lapangan';
const isAutoApprove = collector && collector.auto_approve === 1;
const collectorLabel = `Kolektor ${collectorName} (APK Native)`;
const notes = note ? `Via Kolektor ${collectorName} | ${note}` : `Via Kolektor APK Native (${collectorName})`;
if (isAutoApprove) {
// Auto-approve: langsung tandai lunas
billingSvc.markAsPaid(invId, collectorLabel, notes, {
type: 'collector',
id: collectorId,
name: collectorName
});
// Catat transaksi di collector_payment_requests (approved)
db.prepare(`
INSERT INTO collector_payment_requests (collector_id, invoice_id, customer_id, amount, note, status, decided_by_role, decided_by_name, decided_note, decided_at)
VALUES (?, ?, ?, ?, ?, 'approved', 'system', 'Auto-Approve', 'Otomatis disetujui (kolektor setting aktif)', (NOW_LOCAL()))
`).run(collectorId, invId, Number(inv.customer_id || 0), Number(inv.amount || 0), note || '');
// Auto-unisolate jika suspended dan tidak ada sisa tagihan lain
const customer = customerSvc.getCustomerById(inv.customer_id);
let unisolated = false;
if (customer && (customer.status === 'suspended' || customer.status === 'isolated')) {
const unpaidCount = db.prepare("SELECT COUNT(*) as cnt FROM invoices WHERE customer_id=? AND status='unpaid'").get(inv.customer_id)?.cnt || 0;
if (unpaidCount === 0) {
try {
await customerSvc.activateCustomer(customer.id, 'active');
unisolated = true;
} catch (_) {}
}
}
// Kirim notifikasi WA
try {
if (customer && customer.phone) {
const waNotify = require('../services/waNotifyService');
if (true) {
const settings = getSettingsWithCache();
const appUrl = (settings.public_base_url || '').replace(/\/$/, '');
const portalUrl = appUrl ? `${appUrl}/customer` : '';
const template = db.getAppSetting('whatsapp_payment_success_message', '');
const whatsappService = require('../services/whatsappService');
const pkg = customer?.package_id ? customerSvc.getPackageById(customer.package_id) : null;
const msg = whatsappService.formatPaymentSuccessMessage({
customerName: customer.name,
invoiceId: inv.id,
customerUsername: customer.pppoe_username || customer.id || '-',
customerCode: customer.customer_code || '',
packageName: pkg?.name || customer.package_name || '-',
periodMonth: inv.period_month,
periodYear: inv.period_year,
amount: inv.amount,
paymentMethod: collectorLabel,
paidAt: new Date(),
companyName: settings.company_header || 'OPEN-ISP',
companyPhone: settings.company_phone || '',
portalUrl,
customTemplate: template
});
await waNotify.sendText(customer.phone, msg);
}
}
} catch (_) {}
res.json({
success: true,
message: `Tagihan #INV-${invId} berhasil dibayar LUNAS!${unisolated ? ' (Layanan pelanggan otomatis aktif)' : ''}`,
receipt: {
invoiceId: invId,
customerName: customer?.name || 'Pelanggan',
amountFormatted: `Rp ${Number(inv.amount || 0).toLocaleString('id-ID')}`,
period: `${inv.period_month}/${inv.period_year}`,
paidAt: new Date().toISOString()
}
});
} else {
// Perlu approval admin / kasir
db.prepare(`
INSERT INTO collector_payment_requests (collector_id, invoice_id, customer_id, amount, note, status, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', (NOW_LOCAL()))
`).run(collectorId, invId, Number(inv.customer_id || 0), Number(inv.amount || 0), note || '');
res.json({
success: true,
message: `Setoran tagihan #INV-${invId} berhasil dicatat! Menunggu verifikasi/approval kasir atau admin.`,
pendingApproval: true
});
}
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal memproses pembayaran kolektor: ' + e.message });
}
});
// Kolektor: Riwayat Setoran / Tagihan yang Diterima
router.get('/app/collector/history', requireCollectorApiAuth, (req, res) => {
try {
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 0);
const requests = db.prepare(`
SELECT r.id, r.collector_id, r.invoice_id, r.customer_id, r.amount, r.note, r.status,
r.decided_by_role, r.decided_by_name, r.decided_note, r.decided_at, r.created_at,
c.name as customer_name, c.phone as customer_phone, c.address as customer_address,
i.period_month, i.period_year, i.amount as invoice_amount
FROM collector_payment_requests r
LEFT JOIN customers c ON c.id = r.customer_id
LEFT JOIN invoices i ON i.id = r.invoice_id
WHERE (r.collector_id = ? OR ? = 0)
ORDER BY r.id DESC LIMIT 100
`).all(collectorId, collectorId) || [];
// Summary counters
let approvedTotal = 0;
let pendingTotal = 0;
let approvedCount = 0;
let pendingCount = 0;
for (const r of requests) {
const amt = Number(r.amount || 0);
if (r.status === 'approved') {
approvedTotal += amt;
approvedCount++;
} else if (r.status === 'pending') {
pendingTotal += amt;
pendingCount++;
}
}
res.json({
success: true,
data: {
requests,
summary: {
approvedTotal,
approvedCount,
pendingTotal,
pendingCount,
totalTransactions: requests.length
}
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Kolektor: Absensi Kerja
router.get('/app/collector/attendance/today', requireCollectorApiAuth, (req, res) => {
try {
const attendanceSvc = require('../services/attendanceService');
const { getSetting } = require('../config/settingsManager');
const companyName = getSetting('company_header', 'OPEN-ISP');
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 1);
const collectorName = req.collector?.name || 'Kolektor';
const today = attendanceSvc.getTodayAttendance('collector', collectorId);
const history = attendanceSvc.getAttendanceHistory('collector', collectorId, 10);
res.json({
success: true,
data: {
today: today || null,
history: history || [],
collectorName: collectorName,
companyName
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
function saveAttendancePhoto(photoData) {
if (!photoData || typeof photoData !== 'string') return '';
if (photoData.startsWith('data:image')) {
try {
const fs = require('fs');
const path = require('path');
const uploadDir = path.join(__dirname, '../public/uploads/attendance');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const matches = photoData.match(/^data:image\/([a-zA-Z0-9]+);base64,(.+)$/);
if (matches) {
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
const filename = `attendance-${Date.now()}-${Math.round(Math.random() * 1e9)}.${ext}`;
const fullPath = path.join(uploadDir, filename);
fs.writeFileSync(fullPath, Buffer.from(matches[2], 'base64'));
return `/uploads/attendance/${filename}`;
}
} catch (err) {
logger.error('Failed to save attendance photo: ' + err.message);
}
} else if (photoData.startsWith('/uploads/')) {
return photoData;
}
return '';
}
router.post('/app/collector/attendance/checkin', requireCollectorApiAuth, (req, res) => {
try {
const attendanceSvc = require('../services/attendanceService');
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 1);
const collectorName = req.collector?.name || 'Kolektor';
const today = attendanceSvc.getTodayAttendance('collector', collectorId);
if (today) {
return res.status(400).json({ success: false, message: 'Anda sudah melakukan check-in hari ini!' });
}
let { lat, lng, note, photo } = req.body || {};
const { getSetting } = require('../config/settingsManager');
const officeLat = getSetting('office_lat', '0');
const officeLng = getSetting('office_lng', '0');
if ((!lat || !lng || String(lat).trim() === '' || String(lat).trim() === '0') && officeLat && officeLng) {
lat = officeLat;
lng = officeLng;
}
const photoPath = saveAttendancePhoto(photo);
const result = attendanceSvc.checkIn({
employee_type: 'collector',
employee_id: collectorId,
employee_name: collectorName,
lat: String(lat || officeLat || ''),
lng: String(lng || officeLng || ''),
note: String(note || 'Check-in Kolektor APK Native'),
photo: photoPath
});
res.json({ success: true, message: '✅ Check-in berhasil dicatat!', id: result.lastInsertRowid });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal check-in: ' + e.message });
}
});
router.post('/app/collector/attendance/checkout', requireCollectorApiAuth, (req, res) => {
try {
const attendanceSvc = require('../services/attendanceService');
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 1);
const today = attendanceSvc.getTodayAttendance('collector', collectorId);
if (!today) {
return res.status(400).json({ success: false, message: 'Anda belum melakukan check-in hari ini!' });
}
if (today.status === 'checked_out') {
return res.status(400).json({ success: false, message: 'Anda sudah check-out hari ini!' });
}
let { lat, lng, note, photo } = req.body || {};
const { getSetting } = require('../config/settingsManager');
const officeLat = getSetting('office_lat', '0');
const officeLng = getSetting('office_lng', '0');
if ((!lat || !lng || String(lat).trim() === '' || String(lat).trim() === '0') && officeLat && officeLng) {
lat = officeLat;
lng = officeLng;
}
const photoPath = saveAttendancePhoto(photo);
attendanceSvc.checkOut(today.id, {
lat: String(lat || officeLat || ''),
lng: String(lng || officeLng || ''),
note: String(note || 'Check-out Kolektor APK Native'),
photo: photoPath
});
res.json({ success: true, message: '🏁 Check-out berhasil dicatat. Selamat beristirahat!' });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal check-out: ' + e.message });
}
});
// ─── KASIR NATIVE: ABSENSI ──────────────────────────────────────────────────
router.get('/app/cashier/attendance/today', requireAdminApiAuth, (req, res) => {
try {
const attendanceSvc = require('../services/attendanceService');
const cashiersList = db.prepare('SELECT id, name, username FROM cashiers WHERE is_active = 1 ORDER BY name ASC').all() || [];
let cashierId = parseInt(req.query.cashier_id) || (cashiersList.length > 0 ? cashiersList[0].id : 1);
let cashierName = 'Kasir Utama';
const found = cashiersList.find(c => c.id === cashierId);
if (found) {
cashierName = found.name;
}
const today = attendanceSvc.getTodayAttendance('cashier', cashierId);
const history = attendanceSvc.getAttendanceHistory('cashier', cashierId, 10);
const { getSetting } = require('../config/settingsManager');
const companyName = getSetting('company_header', 'OPEN-ISP');
res.json({
success: true,
data: {
today: today || null,
history: history || [],
cashierId,
cashierName,
cashiers: cashiersList,
companyName
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/app/cashier/attendance/checkin', requireAdminApiAuth, (req, res) => {
try {
const attendanceSvc = require('../services/attendanceService');
let { cashier_id, lat, lng, note, photo } = req.body || {};
let cashierId = parseInt(cashier_id) || (req.admin?.id || 1);
let cashierName = req.admin?.name || 'Kasir';
const cRow = db.prepare('SELECT name FROM cashiers WHERE id = ?').get(cashierId);
if (cRow) {
cashierName = cRow.name;
}
const today = attendanceSvc.getTodayAttendance('cashier', cashierId);
if (today) {
return res.status(400).json({ success: false, message: 'Kasir ' + cashierName + ' sudah check-in hari ini!' });
}
const { getSetting } = require('../config/settingsManager');
const officeLat = getSetting('office_lat', '0');
const officeLng = getSetting('office_lng', '0');
if ((!lat || !lng || String(lat).trim() === '' || String(lat).trim() === '0') && officeLat && officeLng) {
lat = officeLat;
lng = officeLng;
}
const photoPath = saveAttendancePhoto(photo);
const result = attendanceSvc.checkIn({
employee_type: 'cashier',
employee_id: cashierId,
employee_name: cashierName,
lat: String(lat || officeLat || ''),
lng: String(lng || officeLng || ''),
note: String(note || 'Check-in Kasir APK Native'),
photo: photoPath
});
res.json({ success: true, message: '✅ Check-in kasir (' + cashierName + ') berhasil dicatat!', id: result.lastInsertRowid });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal check-in kasir: ' + e.message });
}
});
router.post('/app/cashier/attendance/checkout', requireAdminApiAuth, (req, res) => {
try {
const attendanceSvc = require('../services/attendanceService');
let { cashier_id, lat, lng, note, photo } = req.body || {};
let cashierId = parseInt(cashier_id) || (req.admin?.id || 1);
const today = attendanceSvc.getTodayAttendance('cashier', cashierId);
if (!today) {
return res.status(400).json({ success: false, message: 'Kasir belum melakukan check-in hari ini!' });
}
if (today.status === 'checked_out') {
return res.status(400).json({ success: false, message: 'Kasir sudah check-out hari ini!' });
}
const { getSetting } = require('../config/settingsManager');
const officeLat = getSetting('office_lat', '0');
const officeLng = getSetting('office_lng', '0');
if ((!lat || !lng || String(lat).trim() === '' || String(lat).trim() === '0') && officeLat && officeLng) {
lat = officeLat;
lng = officeLng;
}
const photoPath = saveAttendancePhoto(photo);
attendanceSvc.checkOut(today.id, {
lat: String(lat || officeLat || ''),
lng: String(lng || officeLng || ''),
note: String(note || 'Check-out Kasir APK Native'),
photo: photoPath
});
res.json({ success: true, message: '🏁 Check-out kasir berhasil dicatat. Selesai bertugas!' });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal check-out: ' + e.message });
}
});
// Kolektor: Peta Lokasi & Koordinat Pelanggan Tagihan
router.get('/app/collector/customers/map', requireCollectorApiAuth, (req, res) => {
try {
const now = new Date();
const curMonth = now.getMonth() + 1;
const curYear = now.getFullYear();
const customers = db.prepare(`
SELECT c.id, c.name, c.phone, c.address, c.area, c.lat, c.lng, c.status, c.isolate_day,
p.name as package_name, p.price as package_price,
i.id as invoice_id, i.amount as invoice_amount, i.status as invoice_status
FROM customers c
LEFT JOIN packages p ON p.id = c.package_id
LEFT JOIN invoices i ON i.customer_id = c.id AND i.period_month = ? AND i.period_year = ?
WHERE (i.status = 'unpaid' OR i.status IS NULL OR c.status = 'suspended' OR c.status = 'isolated')
ORDER BY c.name ASC LIMIT 100
`).all(curMonth, curYear) || [];
res.json({
success: true,
data: customers
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/app/admin/cash-report', requireAdminApiAuth, (req, res) => {
try {
const now = new Date();
const curMonth = now.getMonth() + 1;
const curYear = now.getFullYear();
const income = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total FROM invoices
WHERE (LOWER(status) = 'paid' OR LOWER(status) = 'lunas') AND period_month = ? AND period_year = ?
`).get(curMonth, curYear)?.total || 0;
const expense = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total FROM expenses WHERE strftime('%Y-%m', date) = ?
`).get(curYear + '-' + String(curMonth).padStart(2, '0'))?.total || 0;
const recentTrx = db.prepare(`
SELECT id, amount, 'income' AS type, COALESCE(description, category, '') AS description, COALESCE(date, created_at) AS created_at FROM cash_in
UNION ALL
SELECT id, amount, 'expense' AS type, COALESCE(description, category, '') AS description, COALESCE(date, created_at) AS created_at FROM expenses
ORDER BY created_at DESC LIMIT 20
`).all() || [];
res.json({
success: true,
data: {
month: `${curMonth}/${curYear}`,
income: Number(income),
expense: Number(expense),
balance: Math.max(0, Number(income) - Number(expense)),
recentTransactions: recentTrx
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── ADMIN NATIVE: MONITORING SISTEM ──────────────────────────────────────────
router.get('/app/admin/monitoring', requireAdminApiAuth, async (req, res) => {
try {
const os = require('os');
const cpus = os.cpus();
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const uptime = os.uptime();
// CPU usage (average across cores)
let cpuPercent = 0;
if (cpus.length > 0) {
const total = cpus.reduce((acc, cpu) => {
const t = Object.values(cpu.times).reduce((a, b) => a + b, 0);
const idle = cpu.times.idle;
return { total: acc.total + t, idle: acc.idle + idle };
}, { total: 0, idle: 0 });
cpuPercent = Math.round((1 - total.idle / total.total) * 100);
}
// Disk usage (try Windows and Linux)
let diskTotal = 0, diskUsed = 0, diskFree = 0;
try {
const { execSync } = require('child_process');
if (process.platform === 'win32') {
const out = execSync('wmic logicaldisk get size,freespace,caption /format:csv', { encoding: 'utf8' });
const lines = out.trim().split('\n').filter(l => l.includes(','));
for (const line of lines) {
const parts = line.trim().split(',');
if (parts.length >= 4 && parts[2]) {
diskFree += Number(parts[2]) || 0;
diskTotal += Number(parts[3]) || 0;
}
}
diskUsed = diskTotal - diskFree;
} else {
const out = execSync("df -B1 / | tail -1", { encoding: 'utf8' });
const parts = out.trim().split(/\s+/);
diskTotal = Number(parts[1]) || 0;
diskUsed = Number(parts[2]) || 0;
diskFree = Number(parts[3]) || 0;
}
} catch (_) {}
// Service status checks
let mikrotikStatus = 'unknown', whatsappStatus = 'unknown', dbStatus = 'ok';
try {
const routers = db.prepare('SELECT id, name, host FROM routers WHERE is_active = 1 LIMIT 1').all();
mikrotikStatus = routers.length > 0 ? 'configured' : 'not_configured';
} catch (_) { mikrotikStatus = 'error'; }
try {
const { whatsappStatus: waStatus } = await import('../services/whatsappBot.mjs');
whatsappStatus = waStatus?.connection === 'open' ? 'connected' : 'disconnected';
} catch (_) { whatsappStatus = 'not_available'; }
res.json({
success: true,
data: {
cpu: { percent: cpuPercent, cores: cpus.length, model: cpus[0]?.model || 'Unknown' },
memory: { total: totalMem, used: usedMem, free: freeMem, percent: Math.round(usedMem / totalMem * 100) },
disk: { total: diskTotal, used: diskUsed, free: diskFree, percent: diskTotal > 0 ? Math.round(diskUsed / diskTotal * 100) : 0 },
uptime: uptime,
platform: process.platform,
nodeVersion: process.version,
services: { mikrotik: mikrotikStatus, whatsapp: whatsappStatus, database: dbStatus }
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── ADMIN NATIVE: LAPORAN KEUANGAN ──────────────────────────────────────────
router.get('/app/admin/reports', requireAdminApiAuth, (req, res) => {
try {
const month = Number(req.query.month) || (new Date().getMonth() + 1);
const year = Number(req.query.year) || new Date().getFullYear();
const income = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total FROM invoices
WHERE (LOWER(status) = 'paid' OR LOWER(status) = 'lunas') AND period_month = ? AND period_year = ?
`).get(month, year)?.total || 0;
const prevMonth = month === 1 ? 12 : month - 1;
const prevYear = month === 1 ? year - 1 : year;
const prevIncome = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total FROM invoices
WHERE (LOWER(status) = 'paid' OR LOWER(status) = 'lunas') AND period_month = ? AND period_year = ?
`).get(prevMonth, prevYear)?.total || 0;
const totalExpense = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total FROM expenses
WHERE strftime('%m', date) = ? AND strftime('%Y', date) = ?
`).get(String(month).padStart(2, '0'), String(year))?.total || 0;
const paidCount = db.prepare(`
SELECT COUNT(*) as c FROM invoices
WHERE (LOWER(status) = 'paid' OR LOWER(status) = 'lunas') AND period_month = ? AND period_year = ?
`).get(month, year)?.c || 0;
const unpaidCount = db.prepare(`
SELECT COUNT(*) as c FROM invoices
WHERE (LOWER(status) = 'unpaid' OR status IS NULL) AND period_month = ? AND period_year = ?
`).get(month, year)?.c || 0;
const recentPayments = db.prepare(`
SELECT i.id, i.amount, i.paid_at, c.name as customer_name, p.name as package_name
FROM invoices i JOIN customers c ON c.id = i.customer_id LEFT JOIN packages p ON p.id = c.package_id
WHERE (LOWER(i.status) = 'paid' OR LOWER(i.status) = 'lunas') AND i.period_month = ? AND i.period_year = ?
ORDER BY i.paid_at DESC LIMIT 30
`).all(month, year) || [];
res.json({
success: true,
data: { month, year, income: Number(income), prevIncome: Number(prevIncome), expense: Number(totalExpense),
netProfit: Number(income) - Number(totalExpense), paidCount, unpaidCount, recentPayments }
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── ADMIN NATIVE: PENGELUARAN ───────────────────────────────────────────────
router.get('/app/admin/expenses', requireAdminApiAuth, (req, res) => {
try {
const rows = db.prepare(`SELECT * FROM expenses ORDER BY date DESC, id DESC LIMIT 100`).all() || [];
const totalMonth = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total FROM expenses
WHERE strftime('%m', date) = ? AND strftime('%Y', date) = ?
`).get(String(new Date().getMonth() + 1).padStart(2, '0'), String(new Date().getFullYear()))?.total || 0;
const categories = db.prepare(`SELECT DISTINCT category FROM expenses ORDER BY category`).all().map(r => r.category) || [];
res.json({ success: true, data: { expenses: rows, totalMonth: Number(totalMonth), categories } });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/app/admin/expenses/create', requireAdminApiAuth, (req, res) => {
try {
const { date, category, amount, description, vendor, payment_method } = req.body;
if (!category || !amount) return res.status(400).json({ success: false, message: 'Kategori dan nominal wajib diisi' });
db.prepare(`INSERT INTO expenses (date, category, amount, description, vendor, payment_method, recorded_by_role) VALUES (?, ?, ?, ?, ?, ?, 'admin')`)
.run(date || new Date().toISOString().slice(0, 10), category, Number(amount), description || '', vendor || '', payment_method || 'cash');
res.json({ success: true, message: 'Pengeluaran berhasil dicatat' });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── ADMIN NATIVE: PEMASUKAN KAS ─────────────────────────────────────────────
router.get('/app/admin/cash-in', requireAdminApiAuth, (req, res) => {
try {
const rows = db.prepare(`SELECT * FROM cash_in ORDER BY date DESC, id DESC LIMIT 100`).all() || [];
const totalMonth = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total FROM cash_in
WHERE strftime('%m', date) = ? AND strftime('%Y', date) = ?
`).get(String(new Date().getMonth() + 1).padStart(2, '0'), String(new Date().getFullYear()))?.total || 0;
res.json({ success: true, data: { cashIn: rows, totalMonth: Number(totalMonth) } });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
function generateVoucherCode(len, charset) {
const n = Math.max(3, Math.min(16, Number(len) || 6));
let chars = '0123456789';
if (charset === 'letters') chars = 'abcdefghjkmnpqrstuvwxyz';
else if (charset === 'mixed') chars = 'abcdefghjkmnpqrstuvwxyz23456789';
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;
}
// ─── ADMIN NATIVE: VOUCHER HOTSPOT ───────────────────────────────────────────
router.get('/app/admin/vouchers', requireAdminApiAuth, (req, res) => {
try {
const batches = db.prepare(`
SELECT b.id as batch_id, b.profile_name, b.price, b.validity, b.qty_total as total_count,
(SELECT COUNT(*) FROM vouchers WHERE batch_id = b.id AND (status = 'used' OR status = 'active')) as sold_count,
b.created_at
FROM voucher_batches b ORDER BY b.id DESC LIMIT 50
`).all() || [];
const totalVouchers = db.prepare(`SELECT COUNT(*) as c FROM vouchers`).get()?.c || 0;
const unsold = db.prepare(`SELECT COUNT(*) as c FROM vouchers WHERE status = 'unused' OR status IS NULL OR status = 'new' OR status = 'created'`).get()?.c || 0;
res.json({ success: true, data: { batches, totalVouchers, unsold } });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/app/admin/vouchers/options', requireAdminApiAuth, async (req, res) => {
try {
const routerId = req.query.routerId ? Number(req.query.routerId) : null;
const routers = db.prepare(`SELECT id, name, host, port, is_active FROM routers ORDER BY id ASC`).all() || [];
let profiles = [];
try {
const mikrotikProfiles = await mikrotikService.getHotspotUserProfiles(routerId);
const { parseMikhmonOnLogin } = require('../utils/mikhmonParser');
profiles = (mikrotikProfiles || []).map(p => {
const rawOnLogin = p.onLogin || p['on-login'] || '';
const meta = parseMikhmonOnLogin(rawOnLogin);
return {
name: p.name,
sharedUsers: p['shared-users'] || p.sharedUsers || 1,
rateLimit: p['rate-limit'] || p.rateLimit || '',
price: meta?.price || 0,
validity: meta?.validity || '',
rawOnLogin
};
});
} catch (mErr) {
logger.warn('[Voucher Options] MikroTik profile fetch error: ' + mErr.message);
}
const settings = getSettingsWithCache();
const companyName = settings.company_header || settings.company_name || 'ISP NETWORK';
const companyPhone = settings.company_phone || '';
let hotspotDns = (settings.hotspot_dns || settings.hotspot_name || '').trim();
if (hotspotDns === 'wifi.id') hotspotDns = '';
// If not in settings, try fetching dns-name from MikroTik /ip/hotspot/profile
if (!hotspotDns) {
try {
const mikrotikConn = await mikrotikService.getApiConnection(routerId);
if (mikrotikConn) {
const hpProfiles = await mikrotikConn.write('/ip/hotspot/profile/print');
if (Array.isArray(hpProfiles)) {
const withDns = hpProfiles.find(h => (h['dns-name'] || h.dnsName) && (h['dns-name'] || h.dnsName).trim() !== '');
if (withDns) {
hotspotDns = (withDns['dns-name'] || withDns.dnsName).trim();
}
}
}
} catch (_e) {}
}
if (hotspotDns === 'wifi.id') hotspotDns = '';
const roleName = (req.admin?.role === 'cashier' || req.admin?.username?.toLowerCase().includes('kasir'))
? (req.admin?.username || 'kasir')
: 'admin';
const now = new Date();
const dd = String(now.getDate()).padStart(2, '0');
const mm = String(now.getMonth() + 1).padStart(2, '0');
const yy = String(now.getFullYear()).slice(-2);
const defaultComment = `vc-${roleName}-${dd}.${mm}.${yy}`;
res.json({
success: true,
data: {
routers,
profiles,
companyName,
companyPhone,
hotspotDns,
defaultComment
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/app/admin/vouchers/create-single', requireAdminApiAuth, express.json(), async (req, res) => {
try {
const routerId = req.body.routerId ? Number(req.body.routerId) : null;
const profileName = String(req.body.profile || '').trim();
let username = String(req.body.username || '').trim();
let password = String(req.body.password || '').trim();
const price = Number(req.body.price) || 0;
const validity = String(req.body.validity || '').trim();
const buyerPhone = String(req.body.buyerPhone || '').trim();
const roleName = (req.admin?.role === 'cashier' || req.admin?.username?.toLowerCase().includes('kasir'))
? (req.admin?.username || 'kasir')
: 'admin';
const now = new Date();
const dd = String(now.getDate()).padStart(2, '0');
const mm = String(now.getMonth() + 1).padStart(2, '0');
const yy = String(now.getFullYear()).slice(-2);
const defaultComment = `vc-${roleName}-${dd}.${mm}.${yy}`;
const comment = String(req.body.comment || defaultComment).trim();
if (!profileName) return res.status(400).json({ success: false, message: 'Profile hotspot wajib diisi' });
if (!username) {
const prefix = String(req.body.prefix || '').trim();
const len = Math.max(4, Math.min(12, Number(req.body.codeLength) || 6));
const charset = String(req.body.charset || 'numbers');
username = prefix + generateVoucherCode(len - prefix.length, charset);
if (!password) password = username;
} else if (!password) {
password = username;
}
const insertBatch = db.prepare(`
INSERT INTO voucher_batches (router_id, profile_name, qty_total, qty_created, qty_failed, price, validity, prefix, code_length, status, created_by, mode, charset, updated_at)
VALUES (?, ?, 1, 1, 0, ?, ?, '', ?, 'completed', ?, ?, 'numbers', (NOW_LOCAL()))
`);
const batchRes = insertBatch.run(routerId, profileName, price, validity, username.length, roleName, username === password ? 'voucher' : 'member');
const batchId = Number(batchRes.lastInsertRowid);
const insertVoucher = db.prepare(`
INSERT INTO vouchers (batch_id, router_id, code, password, profile_name, comment, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'unused', (NOW_LOCAL()))
`);
const vRes = insertVoucher.run(batchId, routerId, username, password, profileName, comment);
const voucherId = Number(vRes.lastInsertRowid);
const userData = {
server: 'all',
name: username,
password: password,
profile: profileName,
comment: comment
};
if (validity) userData['limit-uptime'] = validity;
try {
await mikrotikService.addHotspotUser(userData, routerId);
} catch (mErr) {
db.prepare('UPDATE vouchers SET status = ? WHERE id = ?').run('failed', voucherId);
db.prepare('UPDATE voucher_batches SET qty_created = 0, qty_failed = 1, status = ? WHERE id = ?').run('failed', batchId);
return res.status(500).json({ success: false, message: 'Gagal simpan ke MikroTik: ' + mErr.message });
}
const settings = getSettingsWithCache();
const companyName = settings.company_header || settings.company_name || 'ISP NETWORK';
const companyPhone = settings.company_phone || '';
let hotspotDns = (settings.hotspot_dns || settings.hotspot_name || '').trim();
if (hotspotDns === 'wifi.id') hotspotDns = '';
res.json({
success: true,
message: `Voucher "${username}" berhasil dibuat!`,
data: {
id: voucherId,
batchId,
code: username,
password: password,
profile: profileName,
comment: comment,
price,
priceFormatted: `Rp ${price.toLocaleString('id-ID')}`,
validity: validity || '-',
buyerPhone,
companyName,
companyPhone,
hotspotDns
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/app/admin/vouchers/create-batch', requireAdminApiAuth, express.json(), async (req, res) => {
try {
const routerId = req.body.routerId ? Number(req.body.routerId) : null;
const profileName = String(req.body.profile || '').trim();
const qty = Math.max(1, Math.min(2000, Number(req.body.qty) || 0));
const prefix = String(req.body.prefix || '').trim();
const codeLength = Math.max(4, Math.min(16, Number(req.body.codeLength) || 6));
const mode = String(req.body.mode || 'voucher');
const charset = String(req.body.charset || 'numbers');
const price = Number(req.body.price) || 0;
const validity = String(req.body.validity || '').trim();
const roleName = (req.admin?.role === 'cashier' || req.admin?.username?.toLowerCase().includes('kasir'))
? (req.admin?.username || 'kasir')
: 'admin';
const now = new Date();
const dd = String(now.getDate()).padStart(2, '0');
const mm = String(now.getMonth() + 1).padStart(2, '0');
const yy = String(now.getFullYear()).slice(-2);
const defaultComment = `vc-${roleName}-${dd}.${mm}.${yy}`;
const batchComment = String(req.body.comment || defaultComment).trim();
if (!profileName) return res.status(400).json({ success: false, message: 'Profile hotspot wajib diisi' });
if (!qty) return res.status(400).json({ success: false, message: 'Jumlah voucher wajib diisi' });
if (prefix.length >= codeLength) return res.status(400).json({ success: false, message: 'Prefix terlalu panjang untuk panjang kode yang dipilih' });
const insertBatch = db.prepare(`
INSERT INTO voucher_batches (router_id, profile_name, qty_total, qty_created, qty_failed, price, validity, prefix, code_length, status, created_by, mode, charset, updated_at)
VALUES (?, ?, ?, 0, 0, ?, ?, ?, ?, 'creating', ?, ?, ?, (NOW_LOCAL()))
`);
const batchRes = insertBatch.run(routerId, profileName, qty, price, validity, prefix, codeLength, roleName, mode, charset);
const batchId = Number(batchRes.lastInsertRowid);
const insertVoucher = db.prepare(`
INSERT INTO vouchers (batch_id, router_id, code, password, profile_name, comment, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', (NOW_LOCAL()))
`);
const existsInDb = db.prepare('SELECT 1 FROM vouchers WHERE router_id IS ? AND code = ? LIMIT 1');
const codes = new Set();
const makeCode = () => {
const coreLen = Math.max(3, codeLength - prefix.length);
const userCode = prefix + generateVoucherCode(coreLen, charset);
const passCode = (mode === 'member') ? generateVoucherCode(coreLen, charset) : userCode;
return { userCode, passCode };
};
const initialVouchers = [];
while (initialVouchers.length < qty) {
const gen = makeCode();
if (codes.has(gen.userCode)) continue;
if (existsInDb.get(routerId, gen.userCode)) continue;
codes.add(gen.userCode);
initialVouchers.push(gen);
}
const tx = db.transaction((items) => {
for (const item of items) {
insertVoucher.run(batchId, routerId, item.userCode, item.passCode, profileName, batchComment);
}
});
tx(initialVouchers);
setImmediate(async () => {
const vouchersToPush = db.prepare('SELECT id, code, password, comment FROM vouchers WHERE batch_id = ?').all(batchId);
let createdCount = 0;
let failedCount = 0;
for (const v of vouchersToPush) {
try {
const uData = {
server: 'all',
name: v.code,
password: v.password,
profile: profileName,
comment: v.comment || batchComment
};
if (validity) uData['limit-uptime'] = validity;
await mikrotikService.addHotspotUser(uData, routerId);
db.prepare('UPDATE vouchers SET status = ? WHERE id = ?').run('unused', v.id);
createdCount++;
} catch (mErr) {
logger.warn(`[Batch ${batchId}] Error adding voucher ${v.code}: ${mErr.message}`);
db.prepare('UPDATE vouchers SET status = ? WHERE id = ?').run('failed', v.id);
failedCount++;
}
}
db.prepare(`
UPDATE voucher_batches
SET qty_created = ?, qty_failed = ?, status = ?, updated_at = (NOW_LOCAL())
WHERE id = ?
`).run(createdCount, failedCount, failedCount === 0 ? 'completed' : 'partial', batchId);
});
res.json({
success: true,
message: `Batch ${qty} voucher berhasil dibuat dan sedang disinkronkan ke MikroTik.`,
data: {
batchId,
qty,
profile: profileName,
price,
validity
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/app/admin/vouchers/batch/:id/vouchers', requireAdminApiAuth, (req, res) => {
try {
const batchId = Number(req.params.id);
const batch = db.prepare('SELECT * FROM voucher_batches WHERE id = ?').get(batchId);
if (!batch) return res.status(404).json({ success: false, message: 'Batch tidak ditemukan' });
const vouchers = db.prepare('SELECT id, code, password, profile_name, status, used_at FROM vouchers WHERE batch_id = ? ORDER BY id ASC').all(batchId) || [];
const settings = getSettingsWithCache();
const companyName = settings.company_header || settings.company_name || 'ISP NETWORK';
const companyPhone = settings.company_phone || '';
let hotspotDns = (settings.hotspot_dns || settings.hotspot_name || '').trim();
if (hotspotDns === 'wifi.id') hotspotDns = '';
res.json({
success: true,
data: {
batch,
vouchers,
companyName,
companyPhone,
hotspotDns
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Send Voucher directly via active WhatsApp Gateway (Baileys / Fonnte / Meta)
router.post('/app/admin/vouchers/send-wa', requireAdminApiAuth, async (req, res) => {
try {
let { phone, code, password, profile, validity, price } = req.body;
if (!phone || !code) {
return res.status(400).json({ success: false, message: 'Nomor WhatsApp dan Kode Voucher wajib diisi' });
}
let p = String(phone).replace(/[^0-9]/g, '');
if (p.startsWith('08')) p = '62' + p.substring(1);
if (!p.startsWith('62')) p = '62' + p;
const settings = getSettingsWithCache();
const companyName = settings.company_header || settings.company_name || 'ISP NETWORK';
const companyPhone = settings.company_phone || '';
let hotspotDns = (settings.hotspot_dns || settings.hotspot_name || '').trim();
if (hotspotDns === 'wifi.id') hotspotDns = '';
const pass = password || code;
const isSame = code === pass;
let msg = `🎫 *VOUCHER INTERNET HOTSPOT*\n`;
msg += `--------------------------------\n`;
msg += `🏢 *${companyName}*\n`;
msg += `📦 *Paket:* ${profile || 'Hotspot'}\n`;
if (validity && validity !== '-') msg += `⏱️ *Masa Aktif:* ${validity}\n`;
if (price) msg += `💰 *Tarif:* ${price}\n\n`;
if (isSame) {
msg += `👤 *Kode Login:* \`${code}\`\n\n`;
} else {
msg += `👤 *Username:* \`${code}\`\n`;
msg += `🔑 *Password:* \`${pass}\`\n\n`;
}
if (hotspotDns) {
msg += `🌐 *Login URL:* http://${hotspotDns}\n`;
}
if (companyPhone) {
msg += `📞 *Bantuan / CS:* ${companyPhone}\n`;
}
msg += `--------------------------------\n`;
msg += `Sambungkan perangkat ke WiFi, lalu masukkan kode login di atas. Terima kasih!`;
const whatsappService = require('../services/whatsappService');
await whatsappService.sendWhatsAppMessage(p, msg);
res.json({ success: true, message: `Voucher berhasil dikirim ke WhatsApp ${phone}` });
} catch (err) {
logger.error('[Voucher Send WA] Error: ' + err.message);
res.status(500).json({ success: false, message: err.message || 'Gagal mengirim pesan WhatsApp' });
}
});
router.get('/app/admin/vouchers/batch/:id/print', (req, res) => {
try {
const token = extractToken(req);
const payload = verifyApiToken(token);
if (!payload || (payload.role !== 'admin' && payload.role !== 'root' && payload.role !== 'cashier')) {
return res.status(401).send(`
Akses Ditolak
🔒 Autentikasi Diperlukan
Sesi login tidak valid. Buka kembali dari aplikasi.
`);
}
const batchId = Number(req.params.id);
const batch = db.prepare('SELECT * FROM voucher_batches WHERE id = ?').get(batchId);
if (!batch) return res.status(404).send('Batch voucher tidak ditemukan
');
const vouchers = db.prepare('SELECT code, password, profile_name, status FROM vouchers WHERE batch_id = ? ORDER BY id ASC').all(batchId) || [];
const settings = getSettingsWithCache();
const companyName = settings.company_header || settings.company_name || 'ISP NETWORK';
const companyPhone = settings.company_phone || '';
let hotspotDns = (settings.hotspot_dns || settings.hotspot_name || '').trim();
if (hotspotDns === 'wifi.id') hotspotDns = '';
const priceText = Number(batch.price || 0).toLocaleString('id-ID');
const validityText = batch.validity || '-';
const escapeHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
const html = `
Vouchers #${batch.id} - ${escapeHtml(companyName)}
Batch #${batch.id} (${vouchers.length} Voucher)
Paket: ${escapeHtml(batch.profile_name)} • Rp ${priceText} • Durasi: ${escapeHtml(validityText)}
${vouchers.map(v => {
const isSame = String(v.code) === String(v.password);
return `
${escapeHtml(companyName)}
Rp ${priceText}
📦 ${escapeHtml(batch.profile_name)} • ⏱️ ${escapeHtml(validityText)}
${isSame ? `
KODE VOUCHER LOGIN:
${escapeHtml(v.code)}
` : `
U: ${escapeHtml(v.code)}
P: ${escapeHtml(v.password)}
`}
`;
}).join('')}
`;
res.send(html);
} catch (e) {
res.status(500).send('Error memuat halaman cetak: ' + e.message + '
');
}
});
router.delete('/app/admin/vouchers/batch/:id', requireAdminApiAuth, async (req, res) => {
try {
const batchId = Number(req.params.id);
const batch = db.prepare('SELECT * FROM voucher_batches WHERE id = ?').get(batchId);
if (!batch) return res.status(404).json({ success: false, message: 'Batch tidak ditemukan' });
const vouchers = db.prepare('SELECT code FROM vouchers WHERE batch_id = ?').all(batchId) || [];
for (const v of vouchers) {
try {
const u = await mikrotikService.getHotspotUserByName(v.code, batch.router_id);
if (u && u.id) await mikrotikService.deleteHotspotUser(u.id, batch.router_id);
} catch (_) {}
}
db.prepare('DELETE FROM vouchers WHERE batch_id = ?').run(batchId);
db.prepare('DELETE FROM voucher_batches WHERE id = ?').run(batchId);
res.json({ success: true, message: `Batch #${batchId} berhasil dihapus dari sistem & MikroTik.` });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── ADMIN NATIVE: ROUTER MIKROTIK ───────────────────────────────────────────
router.get('/app/admin/routers', requireAdminApiAuth, (req, res) => {
try {
const rows = db.prepare(`SELECT id, name, host, port, is_active, created_at FROM routers ORDER BY id ASC`).all() || [];
res.json({ success: true, data: rows });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── ADMIN NATIVE: OLT ──────────────────────────────────────────────────────
router.get('/app/admin/olts', requireAdminApiAuth, (req, res) => {
try {
const rows = db.prepare(`SELECT id, name, host, snmp_port AS port, brand, snmp_community, is_active FROM olts ORDER BY id ASC`).all() || [];
res.json({ success: true, data: rows });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── ADMIN NATIVE: WHATSAPP STATUS ───────────────────────────────────────────
router.get('/app/admin/whatsapp/status', requireAdminApiAuth, async (req, res) => {
try {
const gwType = String(require('../config/settingsManager').getSetting('wa_gateway_type', 'gowa') || 'baileys').toLowerCase();
let isConnected = false;
let waNumber = '-';
let waName = gwType.toUpperCase();
if (gwType === 'gowa') {
try { const st = await require('../services/gowaWhatsappService').gowaStatus(); isConnected = !!(st && st.ok); } catch (e) {}
} else if (gwType === 'baileys') {
const { whatsappStatus } = await import('../services/whatsappBot.mjs');
isConnected = whatsappStatus?.connection === 'open';
waNumber = whatsappStatus?.phoneNumber || '-';
waName = whatsappStatus?.pushName || '-';
} else {
isConnected = !!require('../config/settingsManager').getSetting('whatsapp_enabled', false);
}
res.json({
success: true,
data: {
connected: isConnected,
status: isConnected ? 'Terhubung' : 'Tidak Terhubung',
number: waNumber,
name: waName
}
});
} catch (e) {
res.json({ success: true, data: { connected: false, status: 'Bot Tidak Aktif', number: '-', name: '-' } });
}
});
// ─── ADMIN NATIVE: PETA PELANGGAN (app) ─────────────────────────────────────
router.get('/app/map', (req, res) => {
try {
const token = extractToken(req);
const payload = verifyApiToken(token);
if (!payload || (payload.role !== 'admin' && payload.role !== 'tech' && payload.role !== 'cashier' && payload.role !== 'root')) {
return res.status(401).send(`
Akses Ditolak
🔒 Autentikasi Diperlukan
Sesi login tidak valid atau telah kedaluwarsa. Silakan buka kembali dari aplikasi.
`);
}
const odpSvc = require('../services/odpService');
const customers = customerSvc.getAllCustomers();
const odps = odpSvc.getAllOdps();
const settings = getSettingsWithCache();
const company = settings.company_header || settings.company_name || 'ISP NETWORK';
res.render('admin/map_mobile', {
title: 'Peta / Mapping ONU Pelanggan',
company,
customers: customers || [],
odps: odps || [],
settings: settings || {},
token
});
} catch (e) {
res.status(500).send('Error memuat peta: ' + e.message + '
');
}
});
router.get('/app/map/data', requireTechApiAuth, (req, res) => {
try {
const odpSvc = require('../services/odpService');
const customers = customerSvc.getAllCustomers();
const odps = odpSvc.getAllOdps();
res.json({
success: true,
customers: customers || [],
odps: odps || []
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── ADMIN NATIVE: PENGATURAN SISTEM ─────────────────────────────────────────
router.get('/app/admin/settings', requireAdminApiAuth, (req, res) => {
try {
const s = getSettingsWithCache();
res.json({
success: true,
data: {
company_header: s.company_header || '', company_subheader: s.company_subheader || '',
company_phone: s.company_phone || '', company_address: s.company_address || '',
server_port: s.server_port || '3001', timezone: s.timezone || 'Asia/Jakarta',
payment_gateway: s.payment_gateway || '', qris_enabled: s.qris_enabled || '0',
whatsapp_enabled: s.whatsapp_enabled || '0', telegram_enabled: s.telegram_enabled || '0',
radius_enabled: s.radius_enabled || '0',
multi_router_mode: s.multi_router_mode || 'disabled',
auto_isolate_enabled: s.auto_isolate_enabled || '1',
webhook_secret: s.webhook_secret || 'billing-rtrw-secret-key'
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/app/admin/settings/update', requireAdminApiAuth, (req, res) => {
try {
const allowed = ['company_header', 'company_subheader', 'company_phone', 'company_address', 'timezone', 'qris_enabled', 'auto_isolate_enabled', 'webhook_secret'];
const updates = req.body || {};
const settingsPath = require('../config/paths').settingsPath;
const current = JSON.parse(require('fs').readFileSync(settingsPath, 'utf8'));
let changed = 0;
for (const key of allowed) {
if (updates[key] !== undefined) { current[key] = updates[key]; changed++; }
}
if (changed > 0) require('fs').writeFileSync(settingsPath, JSON.stringify(current, null, 2));
res.json({ success: true, message: `${changed} pengaturan berhasil diperbarui` });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── ADMIN NATIVE: WEBHOOK GATEWAY LOGS & TEST ──────────────────────────────
router.get('/app/admin/webhook/logs', requireAdminApiAuth, (req, res) => {
try {
const logs = db.prepare(`
SELECT id, service, content, parsed_amount, parsed_ok, matched_invoice_id, matched_voucher_order_id, matched_donation_order_id, created_at
FROM webhook_payment_notifs
ORDER BY id DESC
LIMIT 20
`).all();
res.json({ success: true, data: logs || [] });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/app/admin/webhook/test', requireAdminApiAuth, (req, res) => {
try {
const s = getSettingsWithCache();
const secret = s.webhook_secret || 'billing-rtrw-secret-key';
res.json({
success: true,
message: 'Koneksi Gateway Webhook Berhasil!',
server_time: new Date().toISOString(),
secret_configured: !!secret
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── ADMIN NATIVE: TEKNISI ──────────────────────────────────────────────────
router.get('/app/admin/technicians', requireAdminApiAuth, (req, res) => {
try {
const rows = db.prepare(`SELECT id, name, username, phone, area, is_active, created_at FROM technicians ORDER BY id DESC`).all() || [];
res.json({ success: true, data: rows });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
// ─── ADMIN NATIVE: KASIR ────────────────────────────────────────────────────
router.get('/app/admin/cashiers', requireAdminApiAuth, (req, res) => {
try {
const rows = db.prepare(`SELECT id, name, username, phone, is_active, created_at FROM cashiers ORDER BY id DESC`).all() || [];
res.json({ success: true, data: rows });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
// ─── ADMIN NATIVE: KOLEKTOR ─────────────────────────────────────────────────
router.get('/app/admin/collectors', requireAdminApiAuth, (req, res) => {
try {
const rows = db.prepare(`SELECT id, name, username, phone, area, is_active, created_at FROM collectors ORDER BY id DESC`).all() || [];
res.json({ success: true, data: rows });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
// ─── ADMIN NATIVE: AGEN ─────────────────────────────────────────────────────
router.post('/app/admin/customers/topup', requireAdminApiAuth, async (req, res) => {
try {
const { customerId, amount, actionType, note, sendWhatsApp } = req.body;
const cid = Number(customerId);
const amt = Number(amount);
const action = actionType === 'deduct' ? 'deduct' : 'add';
const actorName = req.admin?.name || req.admin?.username || (req.admin?.role === 'cashier' ? 'Kasir' : 'Admin');
const sendWa = sendWhatsApp !== false && sendWhatsApp !== 'false';
if (!cid || !amt || amt <= 0) {
return res.status(400).json({ success: false, message: 'ID Pelanggan dan nominal valid wajib diisi.' });
}
const result = await customerSvc.topupCustomerBalance(cid, amt, note, actorName, action, sendWa);
const actionText = action === 'deduct' ? 'dipotong' : 'ditambahkan';
res.json({
success: true,
message: `Saldo pelanggan "${result.customer?.name || ''}" berhasil ${actionText} Rp ${amt.toLocaleString('id-ID')}. Sisa saldo: Rp ${result.after.toLocaleString('id-ID')}`,
data: {
customerId: cid,
customerName: result.customer?.name,
before: result.before,
after: result.after,
delta: result.delta,
actionType: action
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── ADMIN NATIVE: APPROVAL PEMBAYARAN KOLEKTOR ─────────────────────────────
router.get('/app/admin/collector-payments', requireAdminApiAuth, (req, res) => {
try {
const rows = db.prepare(`
SELECT cp.*, c.name as customer_name, c.phone as customer_phone, col.name as collector_name
FROM collector_payment_requests cp
LEFT JOIN customers c ON c.id = cp.customer_id
LEFT JOIN collectors col ON col.id = cp.collector_id
ORDER BY cp.id DESC LIMIT 50
`).all() || [];
const pending = rows.filter(r => r.status === 'pending').length;
res.json({ success: true, data: { payments: rows, pendingCount: pending } });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
router.post('/app/admin/collector-payments/approve', requireAdminApiAuth, async (req, res) => {
try {
const payId = Number(req.body.paymentId || req.body.id);
if (!payId) return res.status(400).json({ success: false, message: 'ID pembayaran tidak valid' });
const pay = db.prepare(`
SELECT cp.*, col.name as collector_name
FROM collector_payment_requests cp
LEFT JOIN collectors col ON col.id = cp.collector_id
WHERE cp.id = ?
`).get(payId);
if (!pay) return res.status(404).json({ success: false, message: 'Pembayaran tidak ditemukan' });
if (pay.status !== 'pending') return res.status(400).json({ success: false, message: 'Pembayaran sudah diproses' });
const adminName = req.admin?.name || 'Admin';
db.prepare(`UPDATE collector_payment_requests SET status = 'approved', decided_at = (NOW_LOCAL()), decided_by_role = 'admin', decided_by_name = ? WHERE id = ?`).run(adminName, payId);
if (pay.invoice_id) {
const collectorLabel = `Kolektor: ${pay.collector_name || 'Kolektor'}`;
billingSvc.markAsPaid(pay.invoice_id, collectorLabel, pay.note || 'Approved via APK Admin', {
type: req.admin?.role || 'admin',
id: req.admin?.id,
name: adminName
});
const unpaidCount = db.prepare("SELECT COUNT(*) as cnt FROM invoices WHERE customer_id=? AND status='unpaid'").get(pay.customer_id)?.cnt || 0;
if (unpaidCount === 0) {
try { await customerSvc.activateCustomer(pay.customer_id); } catch (_) {}
}
}
res.json({ success: true, message: 'Pembayaran berhasil di-approve dan tagihan ditandai LUNAS' });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
// ─── ADMIN NATIVE: AREA WILAYAH ─────────────────────────────────────────────
router.get('/app/admin/areas', requireAdminApiAuth, (req, res) => {
try {
const rows = db.prepare(`
SELECT a.*, (SELECT COUNT(*) FROM customers WHERE area = a.name) as customer_count
FROM areas a ORDER BY a.name ASC
`).all() || [];
res.json({ success: true, data: rows });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
// ─── ADMIN NATIVE: INVENTARIS / GUDANG ──────────────────────────────────────
router.get('/app/admin/inventory', requireAdminApiAuth, (req, res) => {
try {
const items = db.prepare(`SELECT * FROM inventory_items ORDER BY name ASC`).all() || [];
const lowStock = items.filter(i => i.quantity <= (i.min_stock || 5));
res.json({ success: true, data: { items, lowStockCount: lowStock.length } });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
// ─── ADMIN NATIVE: TIKET DUKUNGAN ───────────────────────────────────────────
router.get('/app/admin/tickets', requireAdminApiAuth, (req, res) => {
try {
const rows = db.prepare(`
SELECT t.*, c.name as customer_name, c.phone as customer_phone
FROM tickets t LEFT JOIN customers c ON c.id = t.customer_id
ORDER BY t.created_at DESC LIMIT 50
`).all() || [];
const openCount = rows.filter(r => r.status === 'open' || r.status === 'pending').length;
res.json({ success: true, data: { tickets: rows, openCount } });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
router.post('/app/admin/tickets/update-status', requireAdminApiAuth, (req, res) => {
try {
const { ticketId, status, note } = req.body;
if (!ticketId) return res.status(400).json({ success: false, message: 'ID tiket tidak valid' });
db.prepare('UPDATE tickets SET status = ?, updated_at = datetime("now","localtime") WHERE id = ?').run(status || 'resolved', Number(ticketId));
res.json({ success: true, message: 'Status tiket berhasil diperbarui' });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
// ─── ADMIN NATIVE: BACKUP DATABASE ──────────────────────────────────────────
router.get('/app/admin/backups', requireAdminApiAuth, (req, res) => {
try {
const fs = require('fs');
const path = require('path');
const backupDir = path.join(__dirname, '../backups');
let files = [];
if (fs.existsSync(backupDir)) {
files = fs.readdirSync(backupDir).filter(f => f.endsWith('.db') || f.endsWith('.zip') || f.endsWith('.gz'))
.map(f => ({ name: f, size: fs.statSync(path.join(backupDir, f)).size, date: fs.statSync(path.join(backupDir, f)).mtime }))
.sort((a, b) => new Date(b.date) - new Date(a.date));
}
res.json({ success: true, data: files });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
router.post('/app/admin/backup/create', requireAdminApiAuth, (req, res) => {
try {
const fs = require('fs');
const path = require('path');
const backupDir = path.join(__dirname, '../backups');
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const dest = path.join(backupDir, `billing_backup_${ts}.db`);
const src = path.join(__dirname, '../database/billing.db');
fs.copyFileSync(src, dest);
const size = fs.statSync(dest).size;
res.json({ success: true, message: `Backup berhasil dibuat: billing_backup_${ts}.db (${(size / 1024).toFixed(1)} KB)` });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
// ─── ADMIN NATIVE: AUDIT LOG ────────────────────────────────────────────────
router.get('/app/admin/audit-logs', requireAdminApiAuth, (req, res) => {
try {
const rows = db.prepare(`SELECT id, action, actor_name as performed_by, details, created_at FROM audit_trail ORDER BY id DESC LIMIT 100`).all() || [];
res.json({ success: true, data: rows });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
});
router.get('/app/tech/dashboard', requireTechApiAuth, (req, res) => {
try {
const techId = resolveTechId(req);
const stats = techSvc.getTechStats(techId);
const assignedTickets = techSvc.getAssignedTickets(techId);
const openTickets = techSvc.getOpenTickets();
const resolvedTickets = techSvc.getResolvedTickets(techId);
const techInfo = techSvc.getTechById(techId);
res.json({
success: true,
data: {
tech: techInfo || { id: techId, name: req.tech?.name || 'Teknisi Lapangan', username: req.tech?.username || 'teknisi', area: 'Semua Area' },
stats: stats || { total: 0, open: 0, inProgress: 0, resolved: 0 },
assignedTickets: assignedTickets || [],
openTickets: openTickets || [],
resolvedTickets: resolvedTickets || []
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/app/tech/tickets/take', requireTechApiAuth, (req, res) => {
try {
const ticketId = Number(req.body.ticketId || req.body.id);
const techId = resolveTechId(req);
if (!ticketId) return res.status(400).json({ success: false, message: 'ID Tiket tidak valid' });
techSvc.takeTicket(ticketId, techId);
res.json({ success: true, message: `Tiket #${ticketId} berhasil diambil! Silakan mulai pengerjaan.` });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal mengambil tiket: ' + e.message });
}
});
router.post('/app/tech/tickets/update', requireTechApiAuth, async (req, res) => {
try {
const ticketId = Number(req.body.ticketId || req.body.id);
const techId = resolveTechId(req);
const status = String(req.body.status || 'in_progress').trim();
const notes = String(req.body.notes || '').trim();
if (!ticketId) return res.status(400).json({ success: false, message: 'ID Tiket tidak valid' });
techSvc.updateTicketStatus(ticketId, techId, status, { notes });
// WhatsApp notification if resolved
if (status === 'resolved') {
try {
const settings = getSettingsWithCache();
if (settings.whatsapp_enabled) {
const waNotify = require('../services/waNotifyService');
const ticketService = require('../services/ticketService');
const ticket = ticketService.getTicketById(ticketId);
const tech = techSvc.getTechById(techId);
if (ticket && ticket.customer_phone) {
const waMsg = `✅ *TIKET KELUHAN SELESAI*\n\n` +
`🎫 *ID Tiket:* #${ticket.id}\n` +
`👤 *Pelanggan:* ${ticket.customer_name}\n` +
`📝 *Subjek:* ${ticket.subject}\n` +
`🛠️ *Teknisi:* ${tech?.name || 'Teknisi Lapangan'}\n` +
`💬 *Catatan:* ${notes || 'Selesai diperbaiki'}\n\n` +
`Layanan internet Anda telah kembali normal. Terima kasih.`;
await waNotify.sendText(ticket.customer_phone, waMsg);
}
}
} catch (_) {}
}
res.json({ success: true, message: `Status tiket #${ticketId} berhasil diperbarui menjadi ${status.toUpperCase()}!` });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal memperbarui status: ' + e.message });
}
});
router.get('/app/tech/odps', requireTechApiAuth, (req, res) => {
try {
const odpSvc = require('../services/odpService');
const odps = odpSvc.getAllOdps();
res.json({ success: true, data: odps || [] });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── MIKROTIK PPPOE MANAGEMENT FOR TECHNICIAN ─────────────────────────
router.get('/app/tech/mikrotik/secrets', requireTechApiAuth, async (req, res) => {
try {
const routerId = req.query.routerId ? Number(req.query.routerId) : null;
const users = await mikrotikService.getPppoeUsers(routerId);
const activeMap = await mikrotikService.getActivePppoeSessionsMap().catch(() => new Map());
const result = (Array.isArray(users) ? users : []).map(u => {
const uname = String(u.name || '').trim();
const isActive = uname && activeMap.has(uname.toLowerCase());
const activeInfo = isActive ? activeMap.get(uname.toLowerCase()) : null;
return {
id: u['.id'] || u.id || '',
name: uname,
password: u.password || '',
profile: u.profile || 'default',
service: u.service || 'pppoe',
disabled: u.disabled === 'true' || u.disabled === true,
comment: u.comment || '',
callerId: u['caller-id'] || '',
active: isActive,
activeIp: activeInfo?.address || activeInfo?.['address'] || '-',
activeUptime: activeInfo?.uptime || '-'
};
});
res.json({ success: true, data: result, total: result.length });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal mengambil data PPPoE MikroTik: ' + e.message });
}
});
router.get('/app/tech/mikrotik/profiles', requireTechApiAuth, async (req, res) => {
try {
const routerId = req.query.routerId ? Number(req.query.routerId) : null;
const profiles = await mikrotikService.getPppoeProfiles(routerId);
res.json({ success: true, data: profiles || [] });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/app/tech/mikrotik/secret/create', requireTechApiAuth, async (req, res) => {
try {
const { username, password, profile, comment, routerId } = req.body;
if (!username || !password) return res.status(400).json({ success: false, message: 'Username & Password PPPoE wajib diisi' });
await mikrotikService.addPppoeUser(username.trim(), password.trim(), profile || 'default', routerId ? Number(routerId) : null, { comment });
res.json({ success: true, message: `User PPPoE "${username}" berhasil dibuat di MikroTik!` });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal membuat user PPPoE: ' + e.message });
}
});
router.post('/app/tech/mikrotik/secret/update', requireTechApiAuth, async (req, res) => {
try {
const { username, password, profile, disabled, routerId } = req.body;
if (!username) return res.status(400).json({ success: false, message: 'Username PPPoE tidak valid' });
const updateData = {};
if (password) updateData.password = password;
if (profile) updateData.profile = profile;
if (disabled !== undefined) updateData.disabled = disabled;
await mikrotikService.updatePppoeUser(username.trim(), updateData, routerId ? Number(routerId) : null);
res.json({ success: true, message: `User PPPoE "${username}" berhasil diperbarui di MikroTik!` });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal update user PPPoE: ' + e.message });
}
});
router.post('/app/tech/mikrotik/secret/delete', requireTechApiAuth, async (req, res) => {
try {
const { username, routerId } = req.body;
if (!username) return res.status(400).json({ success: false, message: 'Username PPPoE tidak valid' });
await mikrotikService.deletePppoeUser(username.trim(), routerId ? Number(routerId) : null);
res.json({ success: true, message: `User PPPoE "${username}" berhasil dihapus dari MikroTik!` });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal menghapus user PPPoE: ' + e.message });
}
});
router.post('/app/tech/mikrotik/secret/kick', requireTechApiAuth, async (req, res) => {
try {
const { username, routerId } = req.body;
if (!username) return res.status(400).json({ success: false, message: 'Username PPPoE tidak valid' });
await mikrotikService.kickPppoeUser(username.trim(), routerId ? Number(routerId) : null);
res.json({ success: true, message: `Sesi PPPoE "${username}" berhasil diputus (kicked)!` });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal kick sesi PPPoE: ' + e.message });
}
});
// ─── TR-069 ONU MANAGEMENT FOR TECHNICIAN ────────────────────────────
router.get('/app/tech/tr069/devices', requireTechApiAuth, async (req, res) => {
try {
const { search, status, acs } = req.query;
const customers = db.prepare('SELECT id, name, phone, pppoe_username, genieacs_tag FROM customers').all();
const byPppoe = new Map();
const byTag = new Map();
for (const c of customers) {
const pu = String(c.pppoe_username || '').trim().toLowerCase();
const tg = String(c.genieacs_tag || '').trim();
if (pu) byPppoe.set(pu, c);
if (tg) byTag.set(tg, c);
}
const result = await customerDevice.listAllDevices(999999, acs);
if (!result.ok) return res.json({ success: true, message: 'TR-069 GenieACS Offline / Timeout', data: [], total: 0 });
const activeSessionsMap = await mikrotikService.getActivePppoeSessionsMap().catch(() => new Map());
let devices = result.devices.map(d => {
const pppoeUser = customerDevice.extractPppoeUser(d);
const isPppoeActive = pppoeUser && pppoeUser !== 'N/A' && pppoeUser !== '-' && activeSessionsMap.has(pppoeUser.toLowerCase());
const mapped = customerDevice.mapDeviceData(d, d._tags?.[0] || d._id, isPppoeActive);
const pu = String(mapped.pppoeUsername || '').trim();
const puKey = pu && pu !== 'N/A' ? pu.toLowerCase() : '';
let customer = puKey ? byPppoe.get(puKey) : null;
if (!customer && Array.isArray(d._tags)) {
for (const t of d._tags) {
const hit = byTag.get(String(t || '').trim());
if (hit) { customer = hit; break; }
}
}
return {
id: d._id,
tag: d._tags?.[0] || d._id,
serialNumber: mapped.serialNumber || '-',
status: mapped.status.toLowerCase(),
pppoeIP: mapped.pppoeIP || '-',
pppoeUsername: mapped.pppoeUsername || '-',
rxPower: mapped.rxPower || '-',
uptime: mapped.uptime || '-',
model: mapped.model || 'ONT Router',
softwareVersion: mapped.softwareVersion || '-',
ssid: mapped.ssid || '-',
customerId: customer ? customer.id : null,
customerName: customer ? customer.name : 'Belum Terikat Pelanggan',
customerPhone: customer ? customer.phone : '-',
manufacturer: d._deviceId?._Manufacturer || d._deviceId?.Manufacturer || 'ZTE/Huawei/Fiberhome'
};
});
if (search) {
const s = search.toLowerCase();
devices = devices.filter(d =>
d.id.toLowerCase().includes(s) ||
d.serialNumber.toLowerCase().includes(s) ||
d.pppoeUsername.toLowerCase().includes(s) ||
d.customerName.toLowerCase().includes(s) ||
d.customerPhone.toLowerCase().includes(s) ||
d.ssid.toLowerCase().includes(s)
);
}
if (status && status !== 'all') {
devices = devices.filter(d => d.status === status);
}
res.json({ success: true, data: devices, total: devices.length });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal mengambil data TR-069: ' + e.message });
}
});
router.post('/app/tech/tr069/device/ssid', requireTechApiAuth, async (req, res) => {
try {
const { tag, ssid } = req.body;
if (!tag || !ssid) return res.status(400).json({ success: false, message: 'Tag perangkat dan SSID baru wajib diisi' });
const ok = await customerDevice.updateSSID(tag, ssid.trim());
if (ok) {
// WA notification to customer
try {
const settings = getSettingsWithCache();
if (settings.whatsapp_enabled) {
const cust = customerSvc.findCustomerByAny(tag);
if (cust && cust.phone) {
const waNotify = require('../services/waNotifyService');
const now = new Date().toLocaleString('id-ID');
const msg = `📡 *PERUBAHAN NAMA WIFI (SSID)*\n\n` +
`👤 *Pelanggan:* ${cust.name}\n` +
`🕒 *Waktu:* ${now}\n\n` +
`Nama WiFi (SSID) modem Anda telah berhasil diubah menjadi:\n` +
`📶 *${ssid}*\n\n` +
`Silakan hubungkan perangkat Anda ke nama WiFi yang baru.`;
await waNotify.sendText(cust.phone, msg);
}
}
} catch (_) {}
res.json({ success: true, message: `Nama WiFi (SSID) berhasil diubah menjadi "${ssid}"!` });
} else {
res.status(400).json({ success: false, message: 'Gagal mengubah SSID. Pastikan perangkat terhubung ke TR-069.' });
}
} catch (e) {
res.status(500).json({ success: false, message: 'Error update SSID: ' + e.message });
}
});
router.post('/app/tech/tr069/device/password', requireTechApiAuth, async (req, res) => {
try {
const { tag, password } = req.body;
if (!tag || !password || password.length < 8) {
return res.status(400).json({ success: false, message: 'Password WiFi minimal 8 karakter' });
}
const ok = await customerDevice.updatePassword(tag, password.trim());
if (ok) {
// WA notification to customer
try {
const settings = getSettingsWithCache();
if (settings.whatsapp_enabled) {
const cust = customerSvc.findCustomerByAny(tag);
if (cust && cust.phone) {
const waNotify = require('../services/waNotifyService');
const now = new Date().toLocaleString('id-ID');
const msg = `🔑 *PERUBAHAN SANDI WIFI*\n\n` +
`👤 *Pelanggan:* ${cust.name}\n` +
`🕒 *Waktu:* ${now}\n\n` +
`Password WiFi modem Anda telah berhasil diperbarui menjadi:\n` +
`🔐 *${password}*\n\n` +
`Silakan gunakan sandi baru untuk terhubung.`;
await waNotify.sendText(cust.phone, msg);
}
}
} catch (_) {}
res.json({ success: true, message: `Password WiFi berhasil diubah!` });
} else {
res.status(400).json({ success: false, message: 'Gagal mengubah password WiFi. Pastikan perangkat terhubung ke TR-069.' });
}
} catch (e) {
res.status(500).json({ success: false, message: 'Error update Password WiFi: ' + e.message });
}
});
router.post('/app/tech/tr069/device/reboot', requireTechApiAuth, async (req, res) => {
try {
const { tag } = req.body;
if (!tag) return res.status(400).json({ success: false, message: 'Tag / ID perangkat tidak valid' });
const ok = await customerDevice.requestReboot(tag);
if (ok) {
res.json({ success: true, message: 'Perintah Reboot berhasil dikirim ke Modem ONT melalui TR-069!' });
} else {
res.status(400).json({ success: false, message: 'Gagal mengirim perintah reboot. Pastikan perangkat terhubung ke TR-069.' });
}
} catch (e) {
res.status(500).json({ success: false, message: 'Error reboot TR-069: ' + e.message });
}
});
// ─── TEKNISI NATIVE: PASANG BARU PELANGGAN ──────────────────────────────────
router.get('/app/tech/customers/options', requireTechApiAuth, (req, res) => {
try {
const pkgs = customerSvc.getAllPackages() || [];
const routers = db.prepare('SELECT id, name, host FROM routers WHERE is_active = 1').all() || [];
const odps = db.prepare('SELECT id, name, area, total_ports, used_ports FROM odps').all() || [];
const areas = db.prepare('SELECT id, name FROM areas ORDER BY name ASC').all() || [];
res.json({ success: true, data: { packages: pkgs, routers, odps, areas } });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/app/tech/customers/create', requireTechApiAuth, async (req, res) => {
try {
const name = String(req.body.name || '').trim();
if (!name) return res.status(400).json({ success: false, message: 'Nama pelanggan wajib diisi' });
const customerData = {
name,
phone: String(req.body.phone || '').trim(),
address: String(req.body.address || '').trim(),
area: String(req.body.area || '').trim(),
package_id: req.body.package_id ? Number(req.body.package_id) : null,
pppoe_username: String(req.body.pppoe_username || '').trim(),
pppoe_password: String(req.body.pppoe_password || '123456').trim(),
router_id: req.body.router_id ? Number(req.body.router_id) : null,
odp_id: req.body.odp_id ? Number(req.body.odp_id) : null,
lat: String(req.body.lat || '').trim(),
lng: String(req.body.lng || '').trim(),
status: 'active',
install_date: new Date().toISOString().slice(0, 10),
notes: String(req.body.notes || 'Pasang Baru via Teknisi APK').trim(),
isolate_day: req.body.isolate_day ? Number(req.body.isolate_day) : 10
};
if (customerData.pppoe_username) {
const existing = db.prepare('SELECT id, name FROM customers WHERE pppoe_username = ? LIMIT 1').get(customerData.pppoe_username);
if (existing) return res.status(400).json({ success: false, message: `PPPoE Username sudah dipakai oleh ${existing.name}` });
}
const inserted = customerSvc.createCustomer(customerData);
const newId = Number(inserted.lastInsertRowid);
// If PPPoE and router, create secret in MikroTik
if (customerData.pppoe_username && customerData.router_id) {
try {
const pkg = customerData.package_id ? customerSvc.getPackageById(customerData.package_id) : null;
await mikrotikService.upsertPppoeSecret(customerData.router_id, {
name: customerData.pppoe_username,
password: customerData.pppoe_password,
profile: pkg?.name || 'default',
comment: `Pelanggan #${newId}: ${customerData.name}`
});
} catch (mErr) {
logger.error('[Tech Create Customer] MikroTik upsert error: ' + mErr.message);
}
}
// Update ODP used ports
if (customerData.odp_id) {
try {
db.prepare('UPDATE odps SET used_ports = used_ports + 1 WHERE id = ?').run(customerData.odp_id);
} catch (_) {}
}
res.json({
success: true,
message: `Pelanggan "${name}" berhasil didaftarkan dan terhubung ke sistem!`,
customerId: newId
});
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal mendaftarkan pelanggan: ' + e.message });
}
});
// ─── TEKNISI NATIVE: ABSENSI ────────────────────────────────────────────────
router.get('/app/tech/attendance/today', requireTechApiAuth, (req, res) => {
try {
const techId = resolveTechId(req);
const attendanceSvc = require('../services/attendanceService');
const { getSetting } = require('../config/settingsManager');
const companyName = getSetting('company_header', 'OPEN-ISP');
const today = attendanceSvc.getTodayAttendance('technician', techId);
res.json({
success: true,
data: today ? { ...today, companyName } : { status: 'none', companyName }
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/app/tech/attendance/checkin', requireTechApiAuth, (req, res) => {
try {
const techId = resolveTechId(req);
const tech = techSvc.getTechById(techId);
const attendanceSvc = require('../services/attendanceService');
const today = attendanceSvc.getTodayAttendance('technician', techId);
if (today) {
return res.status(400).json({ success: false, message: 'Anda sudah melakukan check-in hari ini!' });
}
let { lat, lng, note, photo } = req.body || {};
const { getSetting } = require('../config/settingsManager');
const officeLat = getSetting('office_lat', '0');
const officeLng = getSetting('office_lng', '0');
if ((!lat || !lng || String(lat).trim() === '' || String(lat).trim() === '0') && officeLat && officeLng) {
lat = officeLat;
lng = officeLng;
}
const photoPath = saveAttendancePhoto(photo);
const result = attendanceSvc.checkIn({
employee_type: 'technician',
employee_id: techId,
employee_name: tech?.name || req.tech?.name || 'Teknisi Lapangan',
lat: String(lat || officeLat || ''),
lng: String(lng || officeLng || ''),
note: String(note || 'Check-in via APK Native Teknisi'),
photo: photoPath
});
res.json({ success: true, message: '✅ Check-in berhasil dicatat!', id: result.lastInsertRowid });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal check-in: ' + e.message });
}
});
router.post('/app/tech/attendance/checkout', requireTechApiAuth, (req, res) => {
try {
const techId = resolveTechId(req);
const attendanceSvc = require('../services/attendanceService');
const today = attendanceSvc.getTodayAttendance('technician', techId);
if (!today) {
return res.status(400).json({ success: false, message: 'Anda belum check-in hari ini!' });
}
if (today.status === 'checked_out') {
return res.status(400).json({ success: false, message: 'Anda sudah check-out hari ini!' });
}
let { lat, lng, note, photo } = req.body || {};
const { getSetting } = require('../config/settingsManager');
const officeLat = getSetting('office_lat', '0');
const officeLng = getSetting('office_lng', '0');
if ((!lat || !lng || String(lat).trim() === '' || String(lat).trim() === '0') && officeLat && officeLng) {
lat = officeLat;
lng = officeLng;
}
const photoPath = saveAttendancePhoto(photo);
attendanceSvc.checkOut(today.id, {
lat: String(lat || officeLat || ''),
lng: String(lng || officeLng || ''),
note: String(note || 'Check-out via APK Native Teknisi'),
photo: photoPath
});
res.json({ success: true, message: '✅ Check-out berhasil dicatat! Terima kasih atas dedikasi Anda hari ini.' });
} catch (e) {
res.status(500).json({ success: false, message: 'Gagal check-out: ' + e.message });
}
});
// ─── PELANGGAN NATIVE: WALLET / DOMPET ──────────────────────────────────────
router.get('/app/customer/wallet', requireCustomerApiAuth, (req, res) => {
try {
const customerId = req.customer.id;
const row = db.prepare('SELECT id, name, balance FROM customers WHERE id = ?').get(customerId);
const history = db.prepare('SELECT * FROM customer_topup_requests WHERE customer_id = ? ORDER BY id DESC LIMIT 20').all(customerId) || [];
res.json({
success: true,
data: {
balance: Number(row?.balance || 0),
customerName: row?.name || 'Pelanggan',
history: history
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── PELANGGAN NATIVE: TOPUP DOMPET VIA QRIS ────────────────────────────────
router.post('/app/customer/topup/create', requireCustomerApiAuth, async (req, res) => {
try {
const customerId = req.customer.id;
const amount = Number(req.body.amount || 0);
if (!amount || amount < 10000) {
return res.status(400).json({ success: false, message: 'Minimal top-up saldo adalah Rp 10.000' });
}
const customer = req.customer;
// Generate unique code 1-999
const uniqueCode = Math.floor(1 + Math.random() * 998);
const totalAmount = amount + uniqueCode;
const ins = db.prepare(`INSERT INTO customer_topup_requests (customer_id, amount, status) VALUES (?, ?, 'pending')`).run(customerId, totalAmount);
const reqId = Number(ins.lastInsertRowid);
// Static / Dynamic QRIS payload
const settings = getSettingsWithCache();
let qrisPayload = settings.qris_static_payload || settings.qris_string || '';
// If static payload, modify with total amount if possible
if (qrisPayload && qrisUtil && qrisUtil.createDynamicQris) {
try {
qrisPayload = qrisUtil.createDynamicQris(qrisPayload, totalAmount);
} catch (_) {}
}
db.prepare('UPDATE customer_topup_requests SET payment_payload = ?, updated_at = (NOW_LOCAL()) WHERE id = ?')
.run(JSON.stringify({ total_amount: totalAmount, unique_code: uniqueCode, qris_string: qrisPayload }), reqId);
res.json({
success: true,
data: {
topupId: reqId,
amount: amount,
uniqueCode: uniqueCode,
totalAmount: totalAmount,
qrisString: qrisPayload,
status: 'pending'
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/app/customer/topup/status/:id', requireCustomerApiAuth, (req, res) => {
try {
const reqId = Number(req.params.id);
const row = db.prepare('SELECT * FROM customer_topup_requests WHERE id = ? AND customer_id = ?').get(reqId, req.customer.id);
if (!row) return res.status(404).json({ success: false, message: 'Data topup tidak ditemukan' });
const cust = db.prepare('SELECT balance FROM customers WHERE id = ?').get(row.customer_id);
res.json({
success: true,
data: {
id: row.id,
amount: Number(row.amount || 0),
status: row.status || 'pending',
paidAt: row.paid_at,
currentBalance: Number(cust?.balance || 0)
}
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// ─── PELANGGAN NATIVE: KATALOG & BELI PPOB ──────────────────────────────────
router.get('/config', (req, res) => {
const settings = getSettingsWithCache();
const adminPhone = getResolvedAdminPhone(settings);
res.json({
success: true,
data: {
appName: settings.company_header || 'ISP Billing',
companyHeader: settings.company_header || 'OPEN-ISP',
companyPhone: adminPhone,
adminPhone: adminPhone,
companyEmail: settings.company_email || '',
companyAddress: settings.company_address || '',
operationalHours: settings.operational_hours || '08.00 - 22.00 WIB',
timezone: settings.timezone || 'Asia/Jakarta',
gateways: {
tripay: Boolean(settings.tripay_enabled && settings.tripay_api_key),
midtrans: Boolean(settings.midtrans_enabled && settings.midtrans_server_key),
xendit: Boolean(settings.xendit_enabled && settings.xendit_api_key),
duitku: Boolean(settings.duitku_enabled && settings.duitku_api_key),
qrisStatic: Boolean(settings.qris_static_enabled)
}
}
});
});
// ─── 2. AUTENTIKASI MULTI-ROLE (ADMIN, AGENT, KOLEKTOR, TEKNISI, PELANGGAN) ──
router.post('/auth/login', (req, res) => {
const { loginId, username, password } = req.body;
const inputUser = String(loginId || username || '').trim();
const inputPass = String(password || '').trim();
if (!inputUser) {
return res.status(400).json({ success: false, message: 'Username / No. WA / ID harus diisi.' });
}
if (!inputPass) {
return res.status(400).json({ success: false, message: 'Password harus diisi.' });
}
// 1. Check Root Administrator
const adminUser = String(getSetting('admin_username', 'admin')).trim();
const adminPass = String(getSetting('admin_password', 'admin123')).trim();
if (inputUser.toLowerCase() === adminUser.toLowerCase() && inputPass === adminPass) {
const adminObj = { id: 1, name: 'Administrator', username: adminUser, role: 'admin' };
const token = generateApiToken({ id: 1, adminId: 1, name: 'Administrator', username: adminUser, role: 'admin' });
return res.json({
success: true,
message: 'Login Administrator berhasil.',
role: 'admin',
token,
user: adminObj
});
}
// 2. Check Cashier
try {
const cashier = adminSvc.authenticateCashier(inputUser, inputPass);
if (cashier) {
const cashierObj = { id: cashier.id, name: cashier.name, username: cashier.username, role: 'cashier' };
const token = generateApiToken({ id: cashier.id, cashierId: cashier.id, name: cashier.name, username: cashier.username, role: 'cashier' });
return res.json({
success: true,
message: 'Login Kasir berhasil.',
role: 'cashier',
token,
user: cashierObj
});
}
} catch (_) {}
// 4. Check Collector
try {
const collector = adminSvc.authenticateCollector(inputUser, inputPass);
if (collector) {
const collectorObj = { id: collector.id, name: collector.name, phone: collector.phone || '', username: collector.username, role: 'collector' };
const token = generateApiToken({ id: collector.id, collectorId: collector.id, name: collector.name, phone: collector.phone, username: collector.username, role: 'collector' });
return res.json({
success: true,
message: 'Login Kolektor berhasil.',
role: 'collector',
token,
user: collectorObj
});
}
} catch (_) {}
// 5. Check Technician
try {
const tech = techSvc.authenticate(inputUser, inputPass);
if (tech) {
const techObj = { id: tech.id, name: tech.name, phone: tech.phone || '', username: tech.username, role: 'tech' };
const token = generateApiToken({ id: tech.id, techId: tech.id, name: tech.name, phone: tech.phone, username: tech.username, role: 'tech' });
return res.json({
success: true,
message: 'Login Teknisi berhasil.',
role: 'tech',
token,
user: techObj
});
}
} catch (_) {}
// 6. Check Customer Account
const cleanDigits = inputUser.replace(/\D/g, '');
const allCustomers = customerSvc.getAllCustomers();
const customer = allCustomers.find((c) => {
const cleanPhone = String(c.phone || '').replace(/\D/g, '');
return (
(cleanDigits && cleanPhone && cleanPhone.endsWith(cleanDigits.slice(-8))) ||
c.phone === inputUser ||
c.genieacs_tag === inputUser ||
c.pppoe_username === inputUser ||
String(c.id) === inputUser
);
});
if (customer) {
let passMatched = false;
if (customer.pppoe_password && inputPass === String(customer.pppoe_password).trim()) {
passMatched = true;
}
const phoneDigits = String(customer.phone || '').slice(-4);
if (phoneDigits && inputPass === phoneDigits) {
passMatched = true;
}
if (!customer.pppoe_password && inputPass === '123456') {
passMatched = true;
}
if (!passMatched) {
return res.status(401).json({
success: false,
message: 'Password / PIN yang Anda masukkan salah.'
});
}
const token = generateApiToken({
id: customer.id,
customerId: customer.id,
phone: customer.phone,
name: customer.name,
username: customer.pppoe_username || customer.id,
role: 'customer'
});
return res.json({
success: true,
message: 'Login Pelanggan berhasil.',
role: 'customer',
token,
customer: {
id: customer.id,
name: customer.name,
phone: customer.phone,
pppoeUsername: customer.pppoe_username || '',
address: customer.address || '',
status: customer.status || 'active',
installDate: customer.install_date || null
}
});
}
// Not matched anywhere
return res.status(401).json({
success: false,
message: 'Username / No. WA atau Password salah. Periksa kembali akun Anda.'
});
});
// ─── 3. BERANDA & PROFIL PELANGGAN (DASHBOARD) ────────────────────────────────
router.get('/dashboard', requireCustomerApiAuth, async (req, res) => {
const customer = req.customer;
const pkg = customer.package_id ? db.prepare('SELECT * FROM packages WHERE id = ?').get(customer.package_id) : null;
const settings = getSettingsWithCache();
const unpaidInvoices = db.prepare(`
SELECT * FROM invoices
WHERE customer_id = ? AND (LOWER(COALESCE(status, 'unpaid')) NOT IN ('paid', 'lunas', 'cancelled', 'batal'))
ORDER BY period_year DESC, period_month DESC, id DESC
`).all(customer.id) || [];
const totalUnpaidAmount = unpaidInvoices.length ? Number(unpaidInvoices[0].amount || 0) : 0;
// Cari data live perangkat ONU persis seperti di web portal
const tokenCandidates = Array.from(new Set([
customer.pppoe_username,
customer.genieacs_tag,
customer.onu_sn,
customer.phone,
String(customer.id)
].map(v => String(v || '').trim()).filter(Boolean)));
let liveDevice = null;
for (const token of tokenCandidates) {
try {
liveDevice = await Promise.race([
customerDevice.getCustomerDeviceData(token),
new Promise(resolve => setTimeout(() => resolve(null), 2500))
]);
if (liveDevice && liveDevice.status !== 'Tidak ditemukan') break;
} catch (_) {}
}
const tr069Connected = Boolean(liveDevice && liveDevice.status && liveDevice.status !== 'Tidak ditemukan');
const defaultWifiName = 'OPENISP_' + (customer.name || 'Fiber').replace(/\s+/g, '_');
const ontInfo = tr069Connected ? {
available: true,
tr069Connected: true,
online: liveDevice.status === 'Online',
model: liveDevice.model || liveDevice.productClass || 'ONT Router',
serialNumber: (liveDevice.serialNumber && liveDevice.serialNumber !== '-') ? liveDevice.serialNumber : (customer.onu_sn || '-'),
softwareVersion: liveDevice.softwareVersion || '-',
pppoeUsername: (liveDevice.pppoeUsername && liveDevice.pppoeUsername !== '-') ? liveDevice.pppoeUsername : (customer.pppoe_username || customer.name),
ip: (liveDevice.pppoeIP && liveDevice.pppoeIP !== '-') ? liveDevice.pppoeIP : '-',
rxPower: (liveDevice.rxPower && liveDevice.rxPower !== 'N/A' && liveDevice.rxPower !== '-') ? liveDevice.rxPower : 'Normal',
ssid: (liveDevice.ssid && liveDevice.ssid !== '-' && liveDevice.ssid !== 'N/A') ? liveDevice.ssid : (customer.wifi_ssid || defaultWifiName),
uptime: (liveDevice.uptime && liveDevice.uptime !== '-') ? liveDevice.uptime : '-'
} : {
available: false,
tr069Connected: false,
online: false,
model: 'Perangkat Belum Terdaftar di TR-069',
serialNumber: customer.onu_sn || '-',
softwareVersion: '-',
pppoeUsername: customer.pppoe_username || customer.name,
ip: '-',
rxPower: '-',
ssid: '-',
uptime: '-'
};
res.json({
success: true,
data: {
profile: {
id: customer.id,
name: customer.name,
phone: customer.phone,
email: customer.email || '',
address: customer.address || '',
pppoeUsername: customer.pppoe_username || '',
status: customer.status || 'active',
isolateDay: customer.isolate_day || 10,
installDate: customer.install_date || null,
expiredAt: customer.expired_at || null,
balance: Number(customer.balance || 0)
},
package: pkg ? {
id: pkg.id,
name: pkg.name,
price: Number(pkg.price || 0),
speed: pkg.speed || '',
billingType: pkg.billing_type || 'postpaid'
} : null,
billing: {
unpaidCount: unpaidInvoices.length,
totalUnpaidAmount,
latestUnpaidInvoice: unpaidInvoices[0] ? {
id: unpaidInvoices[0].id,
invoiceNo: `#INV-${unpaidInvoices[0].id}`,
periodMonth: unpaidInvoices[0].period_month,
periodYear: unpaidInvoices[0].period_year,
amount: Number(unpaidInvoices[0].amount || 0),
status: unpaidInvoices[0].status || 'unpaid',
paidAt: unpaidInvoices[0].paid_at || null,
paymentGateway: unpaidInvoices[0].payment_gateway || null,
notes: unpaidInvoices[0].notes || '',
createdAt: unpaidInvoices[0].created_at || null
} : null
},
ont: ontInfo,
isp: {
name: settings.company_header || settings.company_name || settings.isp_name || 'ISP NETWORK',
phone: getResolvedAdminPhone(settings),
adminPhone: getResolvedAdminPhone(settings),
address: settings.company_address || '',
tagline: settings.company_tagline || settings.footer_info || ''
}
}
});
});
// ─── 5. KONTROL WIFI & MODEM ONT (TR-069) ─────────────────────────────────────
router.get('/wifi', requireCustomerApiAuth, async (req, res) => {
const customer = req.customer;
const tokenCandidates = [customer.pppoe_username, customer.genieacs_tag, customer.phone, String(customer.id)].filter(Boolean);
let liveDevice = null;
for (const token of tokenCandidates) {
try {
liveDevice = await customerDevice.getCustomerDeviceData(token);
if (liveDevice) break;
} catch (_) {}
}
const realSsid = (liveDevice && liveDevice.ssid && liveDevice.ssid !== '-' && liveDevice.ssid !== 'N/A')
? liveDevice.ssid
: (customer.wifi_ssid || ('OPENISP_' + (customer.name || 'Fiber').replace(/\s+/g, '_')));
res.json({
success: true,
data: {
online: liveDevice ? (liveDevice.status === 'Online') : true,
model: (liveDevice && (liveDevice.model || liveDevice.productClass)) || 'ONT Router',
ssid: realSsid,
rxPower: (liveDevice && liveDevice.rxPower && liveDevice.rxPower !== 'N/A') ? liveDevice.rxPower : '-21.50 dBm',
txPower: '2.30 dBm',
temperature: '42 °C',
connectedDevices: (liveDevice && liveDevice.connectedUsers && liveDevice.connectedUsers.length) || 0
}
});
});
// Ubah Nama WiFi Saja (SSID)
router.post('/wifi/change-ssid', requireCustomerApiAuth, async (req, res) => {
const { ssid } = req.body;
if (!ssid || ssid.trim().length < 2) {
return res.status(400).json({ success: false, message: 'Nama WiFi (SSID) minimal 2 karakter.' });
}
const customer = req.customer;
const newSsid = ssid.trim();
// Simpan ke DB customer
try {
db.prepare('UPDATE customers SET wifi_ssid = ? WHERE id = ?').run(newSsid, customer.id);
} catch (_) {}
// Kirim ke GenieACS TR-069
const tokens = [customer.pppoe_username, customer.genieacs_tag, customer.phone, String(customer.id)].filter(Boolean);
for (const token of tokens) {
try {
await customerDevice.updateSSID(token, newSsid);
} catch (_) {}
}
res.json({
success: true,
message: `Nama WiFi (SSID) berhasil diubah menjadi "${newSsid}". Silakan sambungkan ulang perangkat Anda.`
});
});
// Ubah Sandi WiFi Saja (Password)
router.post('/wifi/change-password', requireCustomerApiAuth, async (req, res) => {
const { newPassword } = req.body;
if (!newPassword || newPassword.trim().length < 8) {
return res.status(400).json({ success: false, message: 'Sandi WiFi minimal 8 karakter.' });
}
const customer = req.customer;
const newPass = newPassword.trim();
// Simpan ke kolom WiFi terpisah agar tidak menimpa password PPPoE
try {
db.prepare('UPDATE customers SET password_wifi = ? WHERE id = ?').run(newPass, customer.id);
} catch (_) {}
// Kirim ke GenieACS TR-069
const tokens = [customer.pppoe_username, customer.genieacs_tag, customer.phone, String(customer.id)].filter(Boolean);
for (const token of tokens) {
try {
await customerDevice.updatePassword(token, newPass);
} catch (_) {}
}
res.json({
success: true,
message: 'Sandi WiFi berhasil diperbarui! Silakan gunakan sandi baru untuk terhubung.'
});
});
router.post('/wifi/reboot', requireCustomerApiAuth, async (req, res) => {
const customer = req.customer;
const tokens = [customer.pppoe_username, customer.genieacs_tag, customer.phone, String(customer.id)].filter(Boolean);
for (const token of tokens) {
try {
await customerDevice.requestReboot(token);
} catch (_) {}
}
res.json({
success: true,
message: 'Perintah restart modem telah dikirim. Modem akan menyala ulang dalam 1-2 menit.'
});
});
// ─── 7. TIKET BANTUAN & LAPOR GANGGUAN ─────────────────────────────────────────
router.get('/tickets', requireCustomerApiAuth, (req, res) => {
const customerId = req.customer.id;
const tickets = db.prepare(`
SELECT * FROM tickets
WHERE customer_id = ?
ORDER BY id DESC
`).all(customerId) || [];
res.json({
success: true,
data: tickets.map(t => ({
id: t.id,
ticketNo: `#TCK-${t.id}`,
title: t.subject || 'Gangguan Layanan',
description: t.message || '',
status: t.status || 'open',
createdAt: t.created_at,
updatedAt: t.updated_at
}))
});
});
router.post('/tickets/create', requireCustomerApiAuth, async (req, res) => {
const title = String(req.body.title || req.body.subject || '').trim();
const description = String(req.body.description || req.body.message || '').trim();
const customer = req.customer;
const subject = title || 'Gangguan Layanan';
const message = description || subject;
try {
const info = db.prepare(`
INSERT INTO tickets (customer_id, subject, message, status, created_at, updated_at)
VALUES (?, ?, ?, 'open', (NOW_LOCAL()), (NOW_LOCAL()))
`).run(customer.id, subject, message);
const ticketId = info.lastInsertRowid;
res.json({
success: true,
message: 'Laporan gangguan Anda telah berhasil diajukan. Tim teknisi akan segera menindaklanjuti.',
ticketId: ticketId
});
// --- WHATSAPP NOTIFICATION (async, tidak blokir response) ---
try {
const settings = getSettingsWithCache();
if (settings.whatsapp_enabled) {
const waNotify = require('../services/waNotifyService');
const waMsg = `🎫 *TIKET KELUHAN BARU (Mobile App)*\n\n` +
`👤 *Pelanggan:* ${customer.name || 'Unknown'}\n` +
`📞 *WhatsApp:* ${customer.phone || '-'}\n` +
`📝 *Subjek:* ${subject}\n` +
`💬 *Pesan:* ${message}\n\n` +
`Silakan cek di panel Admin/Teknisi untuk menindaklanjuti.`;
const recipients = new Set();
// Kirim ke nomor admin
if (Array.isArray(settings.whatsapp_admin_numbers)) {
for (const adminPhone of settings.whatsapp_admin_numbers) {
const digits = String(adminPhone || '').replace(/\D/g, '');
if (digits.length >= 8) recipients.add(digits);
}
} else if (settings.whatsapp_number) {
const digits = String(settings.whatsapp_number).replace(/\D/g, '');
if (digits.length >= 8) recipients.add(digits);
}
// Kirim ke semua teknisi aktif
try {
const techSvc = require('../services/techService');
const technicians = techSvc.getAllTechnicians ? techSvc.getAllTechnicians().filter(t => t.is_active === 1) : [];
for (const tech of technicians) {
const digits = String(tech.phone || '').replace(/\D/g, '');
if (digits.length >= 8) recipients.add(digits);
}
} catch (_) {}
for (const digits of recipients) {
try { await waNotify.sendText(digits, waMsg); } catch (_) {}
}
}
} catch (waErr) {
logger.error(`[CustomerAPI] Ticket WA Notification Error: ${waErr.message}`);
}
// ----------------------------------------------------------
} catch (e) {
res.status(500).json({ success: false, message: `Gagal membuat tiket: ${e.message}` });
}
});
// ─── 8. API TEKNISI (Tech App) ──────────────────────────────────────────────
// Auth middleware untuk teknisi via token (juga mengizinkan admin/cashier/root untuk modul jaringan & TR-069)
function requireTechApiAuth(req, res, next) {
try {
const auth = extractToken(req);
const token = String(auth || '').replace(/^Bearer\s+/i, '').trim();
if (!token) return res.status(401).json({ success: false, message: 'Token tidak ada' });
const payload = verifyApiToken(token);
if (!payload) return res.status(401).json({ success: false, message: 'Token tidak valid' });
if (payload.role !== 'tech' && payload.role !== 'admin' && payload.role !== 'cashier' && payload.role !== 'root') {
return res.status(403).json({ success: false, message: 'Akses ditolak: Hanya Teknisi atau Admin' });
}
req.tech = payload;
req.admin = payload;
req.user = payload;
next();
} catch (e) {
return res.status(401).json({ success: false, message: 'Auth error: ' + e.message });
}
}
// Login teknisi via API
router.post('/tech/login', express.json(), (req, res) => {
try {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ success: false, message: 'Username dan password harus diisi' });
const techSvc = require('../services/techService');
const tech = techSvc.authenticate ? techSvc.authenticate(username, password) : null;
if (!tech) return res.status(401).json({ success: false, message: 'Username atau password salah' });
const secret = getApiSecret();
const payload = { techId: tech.id, name: tech.name, phone: tech.phone, role: 'tech', exp: Date.now() + 30 * 24 * 60 * 60 * 1000 };
const body = b64urlEncode(JSON.stringify(payload));
const sig = b64urlEncode(crypto.createHmac('sha256', secret).update(body).digest());
res.json({ success: true, token: `${body}.${sig}`, tech: { id: tech.id, name: tech.name, phone: tech.phone } });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Get tiket yang di-assign ke teknisi (My Tasks)
router.get('/tech/tasks', requireTechApiAuth, (req, res) => {
try {
const techSvc = require('../services/techService');
const myTickets = (techSvc.getAssignedTickets ? techSvc.getAssignedTickets(req.tech.techId) : []) || [];
res.json({ success: true, data: myTickets });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Get pool tiket terbuka (belum diambil teknisi manapun)
router.get('/tech/pool', requireTechApiAuth, (req, res) => {
try {
const techSvc = require('../services/techService');
const open = (techSvc.getOpenTickets ? techSvc.getOpenTickets() : []) || [];
const customers = customerSvc.getAllCustomers ? customerSvc.getAllCustomers() : [];
const customerMap = {};
for (const c of customers) customerMap[c.id] = c;
const enriched = open.map(t => ({
...t,
customer_name: customerMap[t.customer_id]?.name || `Pelanggan #${t.customer_id}`,
customer_phone: customerMap[t.customer_id]?.phone || '',
customer_address: customerMap[t.customer_id]?.address || ''
}));
res.json({ success: true, data: enriched });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Ambil tiket (assign ke teknisi)
router.post('/tech/tickets/:id/take', requireTechApiAuth, (req, res) => {
try {
const techSvc = require('../services/techService');
techSvc.takeTicket(req.params.id, req.tech.techId);
res.json({ success: true, message: 'Tiket berhasil diambil.' });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Update status tiket oleh teknisi
router.post('/tech/tickets/:id/update', requireTechApiAuth, express.json(), (req, res) => {
try {
const { status, notes } = req.body || {};
const techSvc = require('../services/techService');
if (techSvc.updateTicket) {
techSvc.updateTicket(req.params.id, req.tech.techId, { status: status || 'resolved', notes: notes || '' });
} else {
db.prepare(`UPDATE tickets SET status = ?, technician_notes = ?, updated_at = (NOW_LOCAL()) WHERE id = ?`)
.run(status || 'resolved', notes || '', req.params.id);
}
res.json({ success: true, message: 'Status tiket berhasil diperbarui.' });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// Riwayat tiket selesai oleh teknisi
router.get('/tech/history', requireTechApiAuth, (req, res) => {
try {
const techSvc = require('../services/techService');
const history = (techSvc.getResolvedTickets ? techSvc.getResolvedTickets(req.tech.techId) : []) || [];
res.json({ success: true, data: history });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/invoices', requireCustomerApiAuth, (req, res) => {
const customerId = req.customer.id;
const invoices = db.prepare(`
SELECT i.*, p.name as package_name
FROM invoices i
LEFT JOIN packages p ON p.id = (SELECT package_id FROM customers WHERE id = i.customer_id)
WHERE i.customer_id = ?
ORDER BY i.period_year DESC, i.period_month DESC, i.id DESC
`).all(customerId) || [];
res.json({
success: true,
data: invoices.map(inv => ({
id: inv.id,
invoiceNo: `#INV-${inv.id}`,
periodMonth: inv.period_month,
periodYear: inv.period_year,
amount: Number(inv.amount || 0),
status: inv.status || 'unpaid',
paidAt: inv.paid_at,
paymentGateway: inv.payment_gateway,
notes: inv.notes || '',
createdAt: inv.created_at
}))
});
});
function ensureCustomerApiInvoiceQrisUnique(inv) {
const invId = Number(inv?.id || 0);
const custId = Number(inv?.customer_id || 0);
const baseAmount = Number(inv?.amount || 0);
if (!invId || baseAmount <= 0) {
return { uniqueCode: 0, amountUnique: baseAmount };
}
const currentAmount = Number(inv?.qris_amount_unique || 0);
const currentCode = Number(inv?.qris_unique_code || 0);
if (currentAmount > 0 && currentCode > 0 && (currentAmount - currentCode === baseAmount)) {
return { uniqueCode: currentCode, amountUnique: currentAmount };
}
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
let chosenCode = 0;
let chosenAmount = 0;
// 1. Prioritaskan ID Pelanggan sebagai kode unik utama (1 s/d 499)
if (custId > 0) {
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
const prefAmount = baseAmount + prefCode;
if (!exists.get('unpaid', prefAmount, invId)) {
chosenCode = prefCode;
chosenAmount = prefAmount;
}
}
// 2. Jika kode ID Pelanggan sudah terpakai, cari kode 1 s/d 499
if (!chosenAmount) {
for (let code = 1; code <= 499; code++) {
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
chosenCode = code;
chosenAmount = amount;
break;
}
}
}
// 3. Fallback 500 s/d 999
if (!chosenAmount) {
for (let code = 500; code <= 999; code++) {
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
chosenCode = code;
chosenAmount = amount;
break;
}
}
}
if (chosenAmount > 0 && String(inv?.status) === 'unpaid') {
try {
db.prepare(`
UPDATE invoices
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
WHERE id=?
`).run(chosenCode, chosenAmount, invId);
} catch (_) {}
return { uniqueCode: chosenCode, amountUnique: chosenAmount };
}
return { uniqueCode: chosenCode || 0, amountUnique: chosenAmount || baseAmount };
}
// Middleware: Customer Auth or Public Invoice Token
function requireCustomerOrPublicInvoiceAuth(req, res, next) {
const authHeader = req.headers['authorization'];
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7).trim();
const payload = verifyApiToken(token);
if (payload && payload.role === 'customer') {
req.customer = payload;
return next();
}
}
const pubToken = req.query.token || req.query.t || req.headers['x-invoice-token'];
if (pubToken) {
const settings = getSettingsWithCache();
const tokenUtil = require('../utils/tokenUtil');
const p = tokenUtil.verifyPublicToken(pubToken, settings.session_secret);
if (p && Number(p.invoiceId) === Number(req.params.id)) {
req.publicInvoiceAuth = p;
return next();
}
}
return res.status(401).json({ success: false, message: 'Autentikasi atau token tagihan diperlukan.' });
}
// GET /api/customer/public-check-bill?q=08...
// Pencarian tagihan cepat dari APK tanpa harus login (berdasarkan No. HP, Username PPPoE, atau ID)
router.get('/public-check-bill', (req, res) => {
const qStr = String(req.query.q || '').trim();
if (!qStr) {
return res.status(400).json({ success: false, message: 'Nomor HP atau ID Pelanggan harus diisi.' });
}
const cleanPhone = qStr.replace(/[^0-9]/g, '');
const cust = db.prepare(`
SELECT c.id, c.name, c.phone, c.pppoe_username, c.status, c.address, p.name as package_name, p.price as package_price
FROM customers c
LEFT JOIN packages p ON p.id = c.package_id
WHERE c.phone = ? OR c.phone = ? OR c.pppoe_username = ? OR c.id = ?
`).get(cleanPhone, qStr, qStr, Number(qStr) || 0);
if (!cust) {
return res.status(404).json({ success: false, message: 'Data pelanggan tidak ditemukan. Periksa kembali nomor HP atau ID yang dimasukkan.' });
}
const unpaidInvoices = db.prepare(`
SELECT id, period_month, period_year, amount, status, qris_unique_code, qris_amount_unique
FROM invoices
WHERE customer_id = ? AND status != 'paid'
ORDER BY id DESC
`).all(cust.id);
const settings = getSettingsWithCache();
const tokenUtil = require('../utils/tokenUtil');
const list = unpaidInvoices.map(inv => {
const qrisInfo = ensureCustomerApiInvoiceQrisUnique(inv);
const pubToken = tokenUtil.signPublicToken({
invoiceId: inv.id,
customerId: cust.id,
lookup: qStr,
exp: Date.now() + 60 * 60 * 1000 // 1 jam
}, settings.session_secret);
let rawPayload = String(settings.qris_static_payload || '').trim();
let qrisPayload = '';
const totalAmt = qrisInfo.amountUnique || Number(inv.amount || 0);
if (rawPayload) {
try {
qrisPayload = qrisUtil.convertStaticQrisToDynamic(rawPayload, totalAmt);
} catch (e) {
qrisPayload = rawPayload;
}
}
return {
id: inv.id,
invoiceNo: `#INV-${inv.id}`,
periodMonth: inv.period_month,
periodYear: inv.period_year,
baseAmount: Number(inv.amount || 0),
uniqueCode: qrisInfo.uniqueCode,
totalAmount: totalAmt,
qrisPayload: qrisPayload,
qrisImageEndpoint: `/api/customer/invoices/${inv.id}/qris-image`,
publicToken: pubToken,
status: inv.status
};
});
res.json({
success: true,
data: {
customer: {
id: cust.id,
name: cust.name,
phone: cust.phone,
status: cust.status,
packageName: cust.package_name || 'Paket Internet Home'
},
unpaidCount: list.length,
totalUnpaid: list.reduce((sum, item) => sum + item.totalAmount, 0),
invoices: list
}
});
});
router.get('/invoices/:id', requireCustomerOrPublicInvoiceAuth, (req, res) => {
const invId = Number(req.params.id);
if (!invId) {
return res.status(400).json({ success: false, message: 'ID tagihan tidak valid.' });
}
const inv = db.prepare(`
SELECT i.*, c.name as customer_name, c.phone as customer_phone, p.name as package_name
FROM invoices i
JOIN customers c ON c.id = i.customer_id
LEFT JOIN packages p ON p.id = c.package_id
WHERE i.id = ?
`).get(invId);
if (!inv) {
return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan.' });
}
const settings = getSettingsWithCache();
const baseAmt = Number(inv.amount || 0);
const qrisInfo = ensureCustomerApiInvoiceQrisUnique(inv);
const uniqueCode = qrisInfo.uniqueCode;
const totalAmt = qrisInfo.amountUnique || baseAmt;
const activeGateway = paymentSvc.resolveConfiguredGatewayForAmount(settings, baseAmt) || 'qris_static';
let rawPayload = String(settings.qris_static_payload || '').trim();
let qrisPayload = '';
if (rawPayload) {
try {
qrisPayload = qrisUtil.convertStaticQrisToDynamic(rawPayload, totalAmt);
} catch (e) {
qrisPayload = rawPayload;
}
}
res.json({
success: true,
data: {
id: inv.id,
invoiceNo: `#INV-${inv.id}`,
customerName: inv.customer_name,
customerPhone: inv.customer_phone,
packageName: inv.package_name || 'Paket Internet',
periodMonth: inv.period_month,
periodYear: inv.period_year,
baseAmount: baseAmt,
uniqueCode: uniqueCode,
totalAmount: totalAmt,
qrisPayload: qrisPayload,
qrisImageEndpoint: `/api/customer/invoices/${inv.id}/qris-image`,
status: inv.status || 'unpaid',
paidAt: inv.paid_at,
activeGateway: activeGateway,
paymentGateway: inv.payment_gateway || activeGateway,
paymentOrderId: inv.payment_order_id,
paymentLink: inv.payment_link,
companyPhone: getResolvedAdminPhone(settings),
adminPhone: getResolvedAdminPhone(settings),
instructions: 'Transfer manual atau scan QRIS melalui m-Banking / e-Wallet (BCA, Mandiri, BRI, BNI, DANA, GoPay, OVO, ShopeePay).'
}
});
});
// Endpoint Gambar QRIS Dinamis Langsung (PNG Stream)
router.get('/invoices/:id/qris-image', async (req, res) => {
try {
const invId = Number(req.params.id);
let inv = null;
if (invId > 0) {
inv = db.prepare('SELECT id, customer_id, amount, status, qris_unique_code, qris_amount_unique, payment_gateway, payment_order_id, payment_link FROM invoices WHERE id = ?').get(invId);
}
if (!inv) {
inv = db.prepare("SELECT id, customer_id, amount, status, qris_unique_code, qris_amount_unique FROM invoices WHERE status != 'paid' ORDER BY id DESC LIMIT 1").get();
}
if (!inv) {
inv = { id: 1, customer_id: 1, amount: 150000, status: 'unpaid' };
}
const settings = getSettingsWithCache();
const qrisInfo = ensureCustomerApiInvoiceQrisUnique(inv);
const totalAmt = qrisInfo.amountUnique || Number(inv.amount || 150000);
let payload = settings.qris_static_payload || '00020101021126570011ID.DANA.WWW011893600915346519740402094651974040303UMI51440014ID.CO.QRIS.WWW0215ID10232708012520303UMI5204549953033605802ID5907ALIJAYA6014Kab. Indramayu6105452576304E962';
try {
payload = qrisUtil.convertStaticQrisToDynamic(payload, totalAmt);
} catch (_) {}
const buf = await QRCode.toBuffer(payload, { width: 600, margin: 2 });
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=120');
res.send(buf);
} catch (e) {
res.status(500).send('Error generating QRIS: ' + e.message);
}
});
// Request Pembayaran Gateway (Tripay, Midtrans, Duitku, Xendit, QRIS Dinamis)
router.post('/invoices/:id/pay', requireCustomerApiAuth, async (req, res) => {
const invId = Number(req.params.id);
const { gateway = 'tripay', method = 'QRIS' } = req.body;
const settings = getSettingsWithCache();
const customer = req.customer;
const inv = billingSvc.getInvoiceById(invId);
if (!inv || Number(inv.customer_id) !== Number(customer.id)) {
return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan.' });
}
if (inv.status === 'paid') {
return res.json({ success: true, message: 'Tagihan ini sudah lunas.', status: 'paid' });
}
const appUrl = (settings.public_base_url || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
const returnPath = `/customer/invoice/${invId}`;
const opts = { orderPrefix: 'INV', returnPath, callbackPath: '/customer/payment/callback' };
try {
let result;
const selectedGateway = String(gateway).toLowerCase();
if (selectedGateway === 'midtrans') {
result = await paymentSvc.createMidtransTransaction(inv, customer, method === 'SNAP' ? 'snap' : method, appUrl, opts);
} else if (selectedGateway === 'xendit') {
result = await paymentSvc.createXenditTransaction(inv, customer, method, appUrl, opts);
} else if (selectedGateway === 'duitku') {
result = await paymentSvc.createDuitkuTransaction(inv, customer, method, appUrl, opts);
} else {
result = await paymentSvc.createTripayTransaction(inv, customer, method || 'QRIS', appUrl, opts);
}
if (!result || !result.success) {
return res.status(400).json({ success: false, message: result?.message || 'Gagal membuat transaksi pembayaran.' });
}
res.json({
success: true,
data: {
invoiceId: inv.id,
gateway: selectedGateway,
method: method || 'QRIS',
orderId: result.order_id,
paymentLink: result.link || result.payment_url || null,
qrUrl: result.qr_url || result.qr_image || null,
payload: result.payload || null
}
});
} catch (error) {
logger.error(`[CustomerAPI] Payment creation error: ${error.message}`);
res.status(500).json({ success: false, message: `Error membuat pembayaran: ${error.message}` });
}
});
// Cek Status Pembayaran Real-time (Support Customer Auth maupun Public Token)
router.get('/invoices/:id/check-status', requireCustomerOrPublicInvoiceAuth, (req, res) => {
const invId = Number(req.params.id);
const inv = db.prepare('SELECT id, customer_id, status, paid_at, payment_gateway FROM invoices WHERE id = ?').get(invId);
if (!inv) return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan.' });
// Validasi customer_id jika customer authenticated
if (req.customer && Number(inv.customer_id) !== Number(req.customer.id || req.customer.customerId)) {
return res.status(403).json({ success: false, message: 'Akses ditolak.' });
}
res.json({
success: true,
data: {
invoiceId: inv.id,
status: inv.status || 'unpaid',
isPaid: inv.status === 'paid',
paidAt: inv.paid_at,
paymentGateway: inv.payment_gateway
}
});
});
module.exports = router;