Files
open-isp/routes/adminPortal.js
T

8443 lines
370 KiB
JavaScript

/**
* Route Admin Dashboard — termasuk Billing System
*/
const express = require('express');
const router = express.Router();
const { getSetting, getSettings, getSettingsWithCache, saveSettings, getNowLocal, getCurrentDateInTimezone, getCurrentTimeInfo, getNowLocalISO, formatDateLocal, formatTimeLocal, parseDateInTimezone } = require('../config/settingsManager');
const { logger } = require('../config/logger');
const db = require('../config/database');
const customerDevice = require('../services/customerDeviceService');
const customerSvc = require('../services/customerService');
const billingSvc = require('../services/billingService');
const pdfSvc = require('../services/pdfInvoiceService');
const mikrotikService = require('../services/mikrotikService');
const adminSvc = require('../services/adminService');
const oltSvc = require('../services/oltService');
const odpSvc = require('../services/odpService');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const XLSX = require('xlsx');
const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() });
const qrisUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
// Promo slides upload storage
const promoSlidesStorage = multer.diskStorage({
destination: function(req, file, cb) {
const uploadDir = path.resolve(__dirname, '..', 'public', 'uploads', 'promo_slides');
// Ensure directory exists
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: function(req, file, cb) {
// Generate unique filename
const ext = path.extname(file.originalname);
const name = 'slide-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9) + ext;
cb(null, name);
}
});
const promoUpload = multer({
storage: promoSlidesStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB max
fileFilter: function(req, file, cb) {
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Hanya file gambar (JPEG, PNG, GIF, WebP) yang diperbolehkan'), false);
}
}
});
const backupSvc = require('../services/backupService');
const monitoringSvc = require('../services/monitoringService');
const inventorySvc = require('../services/inventoryService');
const auditSvc = require('../services/auditTrailService');
const whatsappService = require('../services/whatsappService');
const { parseMikhmonOnLogin } = require('../utils/mikhmonParser');
const diagnosticsSvc = require('../services/diagnosticsService');
const attendanceSvc = require('../services/attendanceService');
const payrollSvc = require('../services/payrollService');
const sidebarMenuSvc = require('../services/sidebarMenuService');
const areaSvc = require('../services/areaService');
const axios = require('axios');
const crypto = require('crypto');
const _jimpMod = require('jimp');
const Jimp = _jimpMod.Jimp || _jimpMod;
const jsQR = require('jsqr');
const qrisUtil = require('../utils/qrisUtil');
const { MultiFormatReader, BarcodeFormat, DecodeHintType, BinaryBitmap, HybridBinarizer, RGBLuminanceSource } = require('@zxing/library');
const QRCode = require('qrcode');
const acsPortal = require('./acsPortal');
const { uploadAttendance, removeAttendanceFile } = require('../middleware/attendanceUpload');
async function extractQrTextFromImageBuffer(buffer) {
const buf = buffer && Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || []);
if (!buf.length) return '';
return await qrisUtil.decodeQrisPayloadFromBuffer(buf);
}
const pppoeTrafficSamples = new Map();
function prunePppoeTrafficSamples(now) {
for (const [k, v] of pppoeTrafficSamples.entries()) {
if (!v || !v.t || (now - v.t) > 15000) pppoeTrafficSamples.delete(k);
}
}
function numField(obj, keys) {
if (!obj) return 0;
for (const k of keys) {
if (obj[k] !== undefined && obj[k] !== null && obj[k] !== '') {
const n = Number(obj[k]);
if (Number.isFinite(n)) return n;
}
if (obj[String(k).toLowerCase()] !== undefined && obj[String(k).toLowerCase()] !== null && obj[String(k).toLowerCase()] !== '') {
const n = Number(obj[String(k).toLowerCase()]);
if (Number.isFinite(n)) return n;
}
}
return 0;
}
function strField(obj, keys) {
if (!obj) return '';
for (const k of keys) {
const v = obj[k] !== undefined ? obj[k] : obj[String(k).toLowerCase()];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
}
return '';
}
async function invokeRouterOsMenuCommand(menu, command, args) {
if (!menu) return null;
if (typeof menu.call === 'function') return await menu.call(command, args);
if (typeof menu.command === 'function') return await menu.command(command, args);
if (typeof menu.run === 'function') return await menu.run(command, args);
return null;
}
// ─── AUTH ──────────────────────────────────────────────────────────────────
function requireAdmin(req, res, next) {
if (req.session?.isAdmin || req.session?.isCashier) return next();
const adminKey = getSetting('admin_api_key', '');
const providedKey = req.headers['x-admin-key'] || req.query.key;
if (adminKey && providedKey === adminKey) return next();
return res.status(401).json({ error: 'Unauthorized - Admin/Staff access required' });
}
function requireAdminSession(req, res, next) {
if (req.session?.isAdmin || req.session?.isCashier) return next();
return res.redirect('/admin/login');
}
function resolvePaidByName(req, fallback) {
const fb = String(fallback || '').trim();
if (req.session?.isCashier) {
const nm = String(req.session.cashierName || '').trim();
const un = String(req.session.cashierUsername || '').trim();
if (nm && un) return `Kasir ${nm} (@${un})`;
if (nm) return `Kasir ${nm}`;
return 'Kasir';
}
if (req.session?.isAdmin) return fb || 'Admin';
return fb || 'Admin';
}
function routerContextMiddleware(req, res, next) {
try {
const multiRouterMode = getSetting('multi_router_mode', 'disabled') === 'active';
const activeRouters = mikrotikService.getAllRouters() || [];
if (req.query.router_id !== undefined) {
if (req.query.router_id === 'all' || req.query.router_id === '0' || req.query.router_id === '') {
req.session.selected_router_id = null;
} else {
const rid = parseInt(req.query.router_id, 10);
req.session.selected_router_id = isNaN(rid) ? null : rid;
}
}
let selectedRouterId = req.session.selected_router_id ?? null;
if (selectedRouterId && !activeRouters.some(r => r.id === selectedRouterId)) {
selectedRouterId = null;
req.session.selected_router_id = null;
}
res.locals.multiRouterMode = multiRouterMode;
res.locals.allActiveRouters = activeRouters;
res.locals.selectedRouterId = selectedRouterId;
req.selectedRouterId = selectedRouterId;
} catch (e) {
res.locals.multiRouterMode = false;
res.locals.allActiveRouters = [];
res.locals.selectedRouterId = null;
req.selectedRouterId = null;
}
next();
}
router.use(routerContextMiddleware);
router.get('/set-active-router', requireAdminSession, (req, res) => {
const routerId = req.query.router_id;
if (routerId === 'all' || routerId === '0' || !routerId) {
req.session.selected_router_id = null;
} else {
const parsed = parseInt(routerId, 10);
req.session.selected_router_id = isNaN(parsed) ? null : parsed;
}
// Tentukan tujuan redirect yang AMAN. Saat Service Worker aktif, referer
// bisa berupa /sw.js (skrip SW), jadi jangan pernah redirect ke referer mentah.
const host = `${req.protocol}://${req.get('host')}`;
let target = '/admin';
try {
const ref = req.get('referer') || '';
if (ref) {
const u = new URL(ref, host);
if (u.origin === host && u.pathname.startsWith('/admin') && !/\/sw\.js$/i.test(u.pathname)) {
target = u.pathname + u.search;
}
}
} catch (e) {}
return res.redirect(target);
});
async function trySendWhatsappPayment(customerPhone, message) {
try {
if (!getSetting('whatsapp_enabled', false)) return false;
const to = String(customerPhone || '').trim();
if (!to) return false;
const waNotify = require('../services/waNotifyService');
await waNotify.sendText(to, String(message || '').trim());
return true;
} catch {
return false;
}
}
async function sendPaymentSuccessWA(customerPhone, customerName, periodText, amountText, paidBy, extraOpts = {}) {
try {
const defaultTemplate = whatsappService.DEFAULT_PAYMENT_SUCCESS_TEMPLATE;
const template = db.getAppSetting('whatsapp_payment_success_message', defaultTemplate);
const settings = getSettings();
const appUrl = (settings.public_base_url || '').replace(/\/$/, '');
const portalUrl = appUrl ? `${appUrl}/customer` : '';
let periodMonth = '';
let periodYear = '';
if (periodText && String(periodText).includes('/')) {
const p = String(periodText).split('/');
periodMonth = p[0];
periodYear = p[1];
}
const custId = extraOpts.customerId || extraOpts.customer_id;
const formattedMsg = whatsappService.formatPaymentSuccessMessage({
customerName: customerName || 'Pelanggan',
customerId: custId || null,
remainingUnpaidInvoices: extraOpts.remainingUnpaidInvoices || null,
invoiceId: extraOpts.invoiceId || '',
customerUsername: extraOpts.username || custId || '-',
customerCode: extraOpts.customerCode || '',
packageName: extraOpts.packageName || '-',
periodMonth: periodMonth || periodText,
periodYear: periodYear || '',
amount: String(amountText || '0').replace(/[^\d]/g, ''),
paymentMethod: extraOpts.method || 'Kasir / Admin',
receiver: paidBy || '-',
paidAt: extraOpts.paidAt || new Date(),
companyName: company(),
companyPhone: settings.company_phone || '',
csPhone: settings.company_whatsapp || settings.company_phone || '',
portalUrl,
customTemplate: template
});
return await trySendWhatsappPayment(customerPhone, formattedMsg);
} catch (e) {
return false;
}
}
// Middleware strictly for Admin
function restrictToAdmin(req, res, next) {
if (req.session?.isAdmin) return next();
req.session._msg = { type: 'error', text: 'Hanya Admin yang dapat mengakses halaman ini.' };
return res.redirect('/admin');
}
function company() { return getSetting('company_header', 'ISP Admin'); }
function finCfg() {
return {
ppn: Number(getSetting('finance_ppn_pct', '11')) || 0,
pph: Number(getSetting('finance_pph_pct', '22')) || 0,
uso: Number(getSetting('finance_uso_pct', '1.75')) || 0,
bhp: Number(getSetting('finance_bhp_pct', '0.5')) || 0,
inclPpn: String(getSetting('finance_revenue_includes_ppn', '0')) === '1'
};
}
function labaRugi(bruto, keluar, fin) {
fin = fin || finCfg();
const dpp = (fin.inclPpn && fin.ppn > 0) ? (bruto / (1 + fin.ppn / 100)) : bruto;
const ppn = fin.inclPpn ? (bruto - dpp) : (bruto * fin.ppn / 100);
const uso = dpp * fin.uso / 100;
const bhp = dpp * fin.bhp / 100;
const sebelumPph = dpp - keluar - uso - bhp;
const pph = Math.max(0, sebelumPph) * fin.pph / 100;
return { bruto, dpp, ppn: Math.max(0, ppn), uso, bhp, keluar, sebelumPph, pph, bersih: sebelumPph - pph };
}
function flashMsg(req) {
const m = req.session._msg;
delete req.session._msg;
return m || null;
}
function safeAdminPath(rawPath, fallback = '/admin/sidebar-settings') {
const candidate = String(rawPath || '').trim();
if (!candidate.startsWith('/admin')) return fallback;
if (candidate.startsWith('/admin/logout')) return fallback;
return candidate;
}
function requireSidebarMenuAccess(menuKey) {
return (req, res, next) => {
const access = sidebarMenuSvc.evaluateMenuAccess(menuKey, req.session);
if (access.allowed) return next();
if (access.reason === 'hidden') {
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" sedang disembunyikan dari sidebar.` };
return res.redirect('/admin');
}
if (access.reason === 'locked') {
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Hubungi ${sidebarMenuSvc.FEATURE_CONTACT_PHONE} untuk mendapatkan password.` };
return res.redirect('/admin/sidebar-settings');
}
req.session._msg = { type: 'error', text: 'Anda tidak memiliki akses ke menu ini.' };
return res.redirect('/admin');
};
}
function popUpdateLog(req) {
const l = req.session._updateLog;
delete req.session._updateLog;
return l || '';
}
function readTextFileSafe(filePath) {
try {
return String(fs.readFileSync(filePath, 'utf8')).trim();
} catch (e) {
return '';
}
}
function runCmd(cmd, args, cwd) {
try {
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
return { ok: r.status === 0, code: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
} catch (e) {
return { ok: false, code: -1, stdout: '', stderr: String(e?.message || e) };
}
}
function copyDirSync(srcDir, destDir) {
fs.mkdirSync(destDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const ent of entries) {
const src = path.join(srcDir, ent.name);
const dst = path.join(destDir, ent.name);
if (ent.isDirectory()) copyDirSync(src, dst);
else if (ent.isFile()) fs.copyFileSync(src, dst);
}
}
function getGitDefaultBranch(repoRoot) {
let r = runCmd('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], repoRoot);
if (r.ok) {
const ref = String(r.stdout || '').trim();
const m = ref.match(/refs\/remotes\/origin\/(.+)$/);
if (m && m[1]) return m[1].trim();
}
r = runCmd('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot);
if (r.ok) {
const b = String(r.stdout || '').trim();
if (b && b !== 'HEAD') return b;
}
r = runCmd('git', ['rev-parse', '--verify', 'origin/main'], repoRoot);
if (r.ok) return 'main';
r = runCmd('git', ['rev-parse', '--verify', 'origin/master'], repoRoot);
if (r.ok) return 'master';
return 'main';
}
function getUpdateInfo(repoRoot) {
const localVersion = readTextFileSafe(path.join(repoRoot, 'version.txt')) || '-';
const info = { localVersion, remoteVersion: '-', branch: '-', needsUpdate: false, error: '' };
const inside = runCmd('git', ['rev-parse', '--is-inside-work-tree'], repoRoot);
if (!inside.ok) {
info.error = 'Folder ini belum menjadi git repository.';
return info;
}
const branch = getGitDefaultBranch(repoRoot);
info.branch = branch;
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
info.repo = _repo || 'origin';
const fetch = _repo
? runCmd('git', ['fetch', '--prune', 'https://github.com/' + _repo + '.git', branch], repoRoot)
: runCmd('git', ['fetch', '--prune'], repoRoot);
if (!fetch.ok) {
info.error = 'Gagal git fetch: ' + (fetch.stderr || fetch.stdout || '').trim();
return info;
}
const remote = _repo
? runCmd('git', ['show', 'FETCH_HEAD:version.txt'], repoRoot)
: runCmd('git', ['show', `origin/${branch}:version.txt`], repoRoot);
if (!remote.ok) {
info.error = `Tidak bisa membaca version.txt dari GitHub (origin/${branch}).`;
return info;
}
const remoteVersion = String(remote.stdout || '').trim() || '-';
info.remoteVersion = remoteVersion;
info.needsUpdate = Boolean(remoteVersion && remoteVersion !== '-' && remoteVersion !== localVersion);
return info;
}
function genCode(len, charset) {
const n = Math.max(4, Math.min(16, Number(len) || 6));
let chars = '0123456789';
if (charset === 'letters') chars = 'abcdefghjkmnpqrstuvwxyz';
else if (charset === 'mixed') chars = 'abcdefghjkmnpqrstuvwxyz23456789';
let out = '';
for (let i = 0; i < n; i++) {
out += chars.charAt(Math.floor(Math.random() * chars.length));
}
// Avoid starting with 0 if it's only numbers
if (charset === 'numbers' && out[0] === '0') out = '1' + out.slice(1);
return out;
}
async function createVoucherBatchAsync(batchId) {
const batch = db.prepare('SELECT * FROM voucher_batches WHERE id = ?').get(batchId);
if (!batch) return;
const routerId = batch.router_id ?? null;
const vouchers = db.prepare('SELECT id, code, profile_name FROM vouchers WHERE batch_id = ? ORDER BY id ASC').all(batchId);
const updateVoucher = db.prepare('UPDATE vouchers SET code=?, password=?, comment=?, status=?, created_at=created_at WHERE id=?');
const markVoucherCreated = db.prepare('UPDATE vouchers SET status=? WHERE id=?');
const incCreated = db.prepare("UPDATE voucher_batches SET qty_created = qty_created + 1, updated_at = (NOW_LOCAL()) WHERE id=?");
const incFailed = db.prepare("UPDATE voucher_batches SET qty_failed = qty_failed + 1, updated_at = (NOW_LOCAL()) WHERE id=?");
const setBatchStatus = db.prepare("UPDATE voucher_batches SET status=?, updated_at = (NOW_LOCAL()) WHERE id=?");
const existsCode = db.prepare('SELECT 1 FROM vouchers WHERE router_id IS ? AND code = ? LIMIT 1');
const makeUniqueCode = () => {
const prefix = String(batch.prefix || '').trim();
const coreLen = Math.max(4, Math.min(16, (Number(batch.code_length) || 6) - prefix.length));
const userCode = prefix + genCode(coreLen, batch.charset || 'numbers');
let passCode = userCode;
if (batch.mode === 'member') {
passCode = genCode(coreLen, batch.charset || 'numbers');
}
return { userCode, passCode };
};
const poolLimit = 8;
let idx = 0;
const worker = async () => {
while (idx < vouchers.length) {
const current = vouchers[idx++];
let generated = { userCode: current.code, passCode: current.password || current.code };
let attempt = 0;
while (attempt < 10) {
attempt++;
if (existsCode.get(routerId, generated.userCode) && generated.userCode !== current.code) {
generated = makeUniqueCode();
continue;
}
try {
const comment = current.comment || `vc-${batch.created_by || 'admin'}`;
const userData = {
server: 'all',
name: generated.userCode,
password: generated.passCode,
profile: batch.profile_name,
comment
};
if (batch.validity) userData['limit-uptime'] = batch.validity;
await mikrotikService.addHotspotUser(userData, routerId);
if (generated.userCode !== current.code || generated.passCode !== current.password) {
updateVoucher.run(generated.userCode, generated.passCode, comment, 'created', current.id);
} else {
markVoucherCreated.run('created', current.id);
}
incCreated.run(batchId);
break;
} catch (e) {
const msg = String(e?.message || e || '');
const isDup = msg.toLowerCase().includes('already') || msg.toLowerCase().includes('exist') || msg.toLowerCase().includes('duplicate');
if (isDup) {
generated = makeUniqueCode();
continue;
}
markVoucherCreated.run('failed', current.id);
incFailed.run(batchId);
break;
}
}
if (attempt >= 10) {
markVoucherCreated.run('failed', current.id);
incFailed.run(batchId);
}
}
};
setBatchStatus.run('creating', batchId);
const workers = Array.from({ length: poolLimit }, () => worker());
await Promise.all(workers);
const final = db.prepare('SELECT qty_total, qty_created, qty_failed FROM voucher_batches WHERE id=?').get(batchId);
if (final.qty_created >= final.qty_total && final.qty_failed === 0) setBatchStatus.run('ready', batchId);
else if (final.qty_created > 0) setBatchStatus.run('partial', batchId);
else setBatchStatus.run('failed', batchId);
}
// Global locals middleware
router.use((req, res, next) => {
res.locals.session = req.session;
res.locals.sidebarSections = sidebarMenuSvc.getSidebarSections(req.session);
res.locals.sidebarBottomNavItems = sidebarMenuSvc.getBottomNavItems(req.session);
res.locals.settings = getSettings();
res.locals.company = company();
res.locals.formatDateLocal = formatDateLocal;
res.locals.formatTimeLocal = formatTimeLocal;
res.locals.parseDateInTimezone = parseDateInTimezone;
res.locals.getNowLocal = getNowLocal;
res.locals.getCurrentTimeInfo = getCurrentTimeInfo;
next();
});
let loginRateLimiter = (req, res, next) => next();
try {
const rlMod = require('../middleware/rateLimiter');
if (rlMod && typeof rlMod.loginRateLimiter === 'function') {
loginRateLimiter = rlMod.loginRateLimiter;
}
} catch (e) {}
// ─── AUTH ROUTES ───────────────────────────────────────────────────────────
router.get('/login', (req, res) => {
if (req.session?.isAdmin || req.session?.isCashier) return res.redirect('/admin');
res.render('admin/login', { title: 'Admin Login', company: company(), error: null });
});
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => {
const { username, password } = req.body;
if (username === getSetting('admin_username', 'admin') && password === getSetting('admin_password', 'demo-isp123')) {
req.session.isAdmin = true;
req.session.adminUser = username;
return res.redirect('/admin');
}
// Check Cashier
const cashier = adminSvc.authenticateCashier(username, password);
if (cashier) {
req.session.isCashier = true;
req.session.cashierId = cashier.id;
req.session.cashierName = cashier.name;
req.session.cashierUsername = cashier.username;
return res.redirect('/admin');
}
res.render('admin/login', { title: 'Admin Login', company: company(), error: 'Username atau password salah' });
});
router.get('/logout', (req, res) => { req.session.destroy(); res.redirect('/admin/login'); });
// ─── OLT MANAGEMENT ────────────────────────────────────────────────────────
router.get('/olts', requireAdminSession, async (req, res) => {
const olts = oltSvc.getAllOlts();
res.render('admin/olts', {
title: 'Manajemen OLT',
company: company(),
activePage: 'olts',
olts,
msg: flashMsg(req)
});
});
router.get('/olts/all/stats', requireAdminSession, async (req, res) => {
try {
const stats = await oltSvc.getAllOltsStats(req.query.full === 'true');
res.json(stats);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/olts/:id/stats', requireAdminSession, async (req, res) => {
try {
const stats = await oltSvc.getOltStats(req.params.id, req.query.full === 'true');
res.json(stats);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/olts/test-connection', requireAdminSession, restrictToAdmin, express.json(), async (req, res) => {
try {
const result = await oltSvc.testOltConnection(req.body);
res.json(result);
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/olts/:id/test-connection', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const olt = oltSvc.getOltById(req.params.id);
if (!olt) return res.status(404).json({ success: false, message: 'OLT tidak ditemukan' });
const result = await oltSvc.testOltConnection(olt);
res.json(result);
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/olts/:id/onu/:index/reboot', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
await oltSvc.rebootOnu(req.params.id, req.params.index);
res.json({ success: true, message: 'Perintah reboot berhasil dikirim.' });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/olts/:id/onu/:index/rename', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), async (req, res) => {
try {
const { name } = req.body;
if (!name) throw new Error('Nama tidak boleh kosong');
await oltSvc.renameOnu(req.params.id, req.params.index, name);
res.json({ success: true, message: 'Nama ONU berhasil diubah.' });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/olts/:id/onu/authorize', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), async (req, res) => {
try {
const output = await oltSvc.authorizeOnu(req.params.id, req.body);
res.json({ success: true, message: 'Otorisasi berhasil.', output });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/olts/:id/onu/configure-wan', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), async (req, res) => {
try {
const { method, sn } = req.body;
let output;
if (method === 'tr069') {
output = await oltSvc.configureWanViaAcs(sn, req.body);
} else {
output = await oltSvc.configureOnuWan(req.params.id, req.body);
}
res.json({ success: true, message: 'Konfigurasi WAN berhasil.', output });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/olts', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
oltSvc.createOlt(req.body);
req.session._msg = { type: 'success', text: 'OLT berhasil ditambahkan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/olts');
});
router.post('/olts/:id/update', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
oltSvc.updateOlt(req.params.id, req.body);
req.session._msg = { type: 'success', text: 'OLT berhasil diperbarui.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/olts');
});
router.post('/olts/:id/delete', requireAdminSession, restrictToAdmin, (req, res) => {
try {
oltSvc.deleteOlt(req.params.id);
req.session._msg = { type: 'success', text: 'OLT berhasil dihapus.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/olts');
});
// ─── ODP & MAP MANAGEMENT ───────────────────────────────────────────────────
router.get('/map', requireAdminSession, requireSidebarMenuAccess('map'), (req, res) => {
// Kirim HANYA field yang dipakai peta -> payload jauh lebih kecil (hemat di mobile/PWA)
const CUST_FIELDS = ['id', 'name', 'address', 'area', 'lat', 'lng', 'status', 'phone', 'customer_code', 'package_name', 'speed_down', 'speed_up', 'pppoe_username', 'genieacs_tag', 'odp_id', 'odp_name', 'cable_path'];
const ODP_FIELDS = ['id', 'name', 'description', 'lat', 'lng', 'pon_port', 'port_capacity'];
const customers = customerSvc.getAllCustomers().map(function (c) {
const o = {};
CUST_FIELDS.forEach(function (k) { if (c[k] !== undefined) o[k] = c[k]; });
return o;
});
const odps = odpSvc.getAllOdps().map(function (o) {
const t = {};
ODP_FIELDS.forEach(function (k) { if (o[k] !== undefined) t[k] = o[k]; });
t.port_usage = odpSvc.getOdpPortUsage(o.id);
return t;
});
const _s = getSettings();
res.render('admin/map', {
title: 'Peta Jaringan',
company: company(),
activePage: 'map',
customers: customers,
odps: odps,
msg: flashMsg(req),
mapSettings: { office_lat: _s.office_lat, office_lng: _s.office_lng }
});
});
router.get('/api/customers/live-sessions', requireAdminSession, async (req, res) => {
try {
const force = req.query.force === '1' || req.query.force === 'true';
const activeMap = await mikrotikService.getAllActiveSessionsMap(force);
const sessionsObj = {};
for (const [uname, session] of activeMap.entries()) {
sessionsObj[uname] = session;
}
return res.json({ ok: true, sessions: sessionsObj });
} catch (e) {
return res.status(500).json({ ok: false, error: e.message });
}
});
router.get('/api/customers/:id/pppoe-traffic', requireAdminSession, async (req, res) => {
const customerId = Number(req.params.id);
if (!customerId) return res.status(400).json({ ok: false, error: 'invalid_customer' });
const customer = customerSvc.getCustomerById(customerId);
if (!customer) return res.status(404).json({ ok: false, error: 'not_found' });
const routerId = customer.router_id ? Number(customer.router_id) : null;
const username = String(customer.pppoe_username || '').trim();
if (!routerId || !username) {
return res.json({ ok: true, available: false, online: false, username: username || null, rxMbps: 0, txMbps: 0 });
}
const now = Date.now();
prunePppoeTrafficSamples(now);
let conn = null;
try {
conn = await mikrotikService.getConnection(routerId);
const sessions = await conn.client.menu('/ppp/active').where('name', username).get();
if (!sessions || sessions.length === 0) {
return res.json({ ok: true, available: true, online: false, username, rxMbps: 0, txMbps: 0 });
}
const s = sessions[0];
let iface = strField(s, ['interface', 'interface-name', 'interfaceName', 'ifname', 'if-name', 'pppInterface']) || null;
const baseSessionId = strField(s, ['.id', 'id', 'sessionId', 'session-id']) || `${username}`;
const bytesIn = numField(s, ['bytesIn', 'bytes-in', 'bytes_in']);
const bytesOut = numField(s, ['bytesOut', 'bytes-out', 'bytes_out']);
const uptime = strField(s, ['uptime']) || null;
if (!iface) {
try {
const pppoeSrvMenu = conn.client.menu('/interface/pppoe-server');
let pppoeRows = [];
try {
pppoeRows = await pppoeSrvMenu.where('user', username).get();
} catch {
pppoeRows = await pppoeSrvMenu.get();
}
const hit = (Array.isArray(pppoeRows) ? pppoeRows : []).find(r => String(r.user || r['user'] || '').trim() === username);
const ifaceName = strField(hit, ['name']);
if (ifaceName) iface = ifaceName;
} catch {}
}
const sessionId = `${baseSessionId}${iface ? `|${iface}` : ''}`;
const key = `${routerId || 'default'}:${username}`;
const prev = pppoeTrafficSamples.get(key);
let rxBytes = bytesIn;
let txBytes = bytesOut;
let source = 'ppp-active';
if (iface) {
const ifMenu = conn.client.menu('/interface');
if (ifMenu) {
try {
const mtRaw = await invokeRouterOsMenuCommand(ifMenu, 'monitor-traffic', { interface: iface, once: '' });
const mt = Array.isArray(mtRaw) ? mtRaw[0] : mtRaw;
const rxBps = numField(mt, ['rxBitsPerSecond', 'rx-bits-per-second', 'rx-bits-per-second']);
const txBps = numField(mt, ['txBitsPerSecond', 'tx-bits-per-second', 'tx-bits-per-second']);
if (rxBps || txBps) {
return res.json({
ok: true,
available: true,
online: true,
username,
iface,
source: 'monitor-traffic',
uptime,
rxMbps: (Number(rxBps) || 0) / 1e6,
txMbps: (Number(txBps) || 0) / 1e6
});
}
} catch {}
}
}
if (iface) {
try {
const ifRows = await conn.client.menu('/interface').where('name', iface).get();
if (ifRows && ifRows.length > 0) {
const row = ifRows[0];
const ifRx = numField(row, ['rxByte', 'rx-byte', 'rx-bytes', 'rxBytes']);
const ifTx = numField(row, ['txByte', 'tx-byte', 'tx-bytes', 'txBytes']);
if (ifRx || ifTx) {
rxBytes = ifRx;
txBytes = ifTx;
source = 'interface';
}
}
} catch {}
}
pppoeTrafficSamples.set(key, { t: now, sessionId, rxBytes, txBytes, source });
if (!prev || prev.sessionId !== sessionId || !prev.t) {
return res.json({
ok: true,
available: true,
online: true,
warmup: true,
username,
iface,
source,
uptime,
rxMbps: 0,
txMbps: 0
});
}
const dtMs = Math.max(1, now - prev.t);
const dIn = rxBytes - numField(prev, ['rxBytes']);
const dOut = txBytes - numField(prev, ['txBytes']);
if (dIn < 0 || dOut < 0) {
return res.json({
ok: true,
available: true,
online: true,
warmup: true,
username,
iface,
source,
uptime,
rxMbps: 0,
txMbps: 0
});
}
const rxMbps = (dIn * 8) / (dtMs / 1000) / 1e6;
const txMbps = (dOut * 8) / (dtMs / 1000) / 1e6;
return res.json({
ok: true,
available: true,
online: true,
username,
iface,
source,
uptime,
rxMbps: Number.isFinite(rxMbps) ? rxMbps : 0,
txMbps: Number.isFinite(txMbps) ? txMbps : 0
});
} catch (e) {
return res.json({ ok: false, error: e.message || 'failed' });
} finally {
if (conn && conn.api) conn.api.close();
}
});
router.post('/api/customers/:id/cable-path', requireAdminSession, (req, res) => {
try {
const id = Number(req.params.id);
const { path } = req.body;
if (!id) throw new Error('ID pelanggan tidak valid');
customerSvc.updateCustomerCablePath(id, path);
res.json({ ok: true });
} catch (e) {
console.error('[API] Save Cable Path Error:', e);
res.status(500).json({ ok: false, error: e.message });
}
});
router.get('/api/odps/:id/ports', requireAdminSession, (req, res) => {
try {
const usage = odpSvc.getOdpPortUsage(Number(req.params.id));
if (!usage) return res.status(404).json({ error: 'ODP tidak ditemukan' });
res.json(usage);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/odps', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
odpSvc.createOdp(req.body);
req.session._msg = { type: 'success', text: 'ODP berhasil ditambahkan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/map');
});
router.post('/odps/:id/update', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
odpSvc.updateOdp(req.params.id, req.body);
req.session._msg = { type: 'success', text: 'ODP berhasil diperbarui.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/map');
});
router.post('/odps/:id/delete', requireAdminSession, restrictToAdmin, (req, res) => {
try {
odpSvc.deleteOdp(req.params.id);
req.session._msg = { type: 'success', text: 'ODP berhasil dihapus.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/map');
});
// --- TECHNICIAN MANAGEMENT ---
router.get('/technicians', requireAdminSession, requireSidebarMenuAccess('technicians'), restrictToAdmin, (req, res) => {
const technicians = adminSvc.getAllTechnicians();
res.render('admin/technicians', { title: 'Manajemen Teknisi', company: company(), activePage: 'technicians', technicians, msg: flashMsg(req) });
});
router.post('/technicians', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
adminSvc.createTechnician(req.body);
req.session._msg = { type: 'success', text: 'Teknisi berhasil ditambahkan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/technicians');
});
router.post('/technicians/:id/update', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
adminSvc.updateTechnician(req.params.id, req.body);
req.session._msg = { type: 'success', text: 'Data teknisi diperbarui.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/technicians');
});
router.post('/technicians/:id/delete', requireAdminSession, restrictToAdmin, (req, res) => {
adminSvc.deleteTechnician(req.params.id);
req.session._msg = { type: 'success', text: 'Teknisi berhasil dihapus.' };
res.redirect('/admin/technicians');
});
// --- CASHIER MANAGEMENT ---
router.get('/cashiers', requireAdminSession, requireSidebarMenuAccess('cashiers'), restrictToAdmin, (req, res) => {
const cashiers = adminSvc.getAllCashiers();
res.render('admin/cashiers', { title: 'Manajemen Kasir', company: company(), activePage: 'cashiers', cashiers, msg: flashMsg(req) });
});
router.post('/cashiers', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
adminSvc.createCashier(req.body);
req.session._msg = { type: 'success', text: 'Kasir berhasil ditambahkan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/cashiers');
});
router.post('/cashiers/:id/update', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
adminSvc.updateCashier(req.params.id, req.body);
req.session._msg = { type: 'success', text: 'Data kasir diperbarui.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/cashiers');
});
router.post('/cashiers/:id/delete', requireAdminSession, restrictToAdmin, (req, res) => {
adminSvc.deleteCashier(req.params.id);
req.session._msg = { type: 'success', text: 'Kasir berhasil dihapus.' };
res.redirect('/admin/cashiers');
});
// --- COLLECTOR MANAGEMENT ---
router.get('/collectors', requireAdminSession, requireSidebarMenuAccess('collectors'), restrictToAdmin, (req, res) => {
const collectors = adminSvc.getAllCollectors();
const masterAreas = areaSvc.getAllAreas();
res.render('admin/collectors', { title: 'Manajemen Kolektor', company: company(), activePage: 'collectors', collectors, masterAreas, msg: flashMsg(req) });
});
router.post('/collectors', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
adminSvc.createCollector(req.body);
req.session._msg = { type: 'success', text: 'Kolektor berhasil ditambahkan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/collectors');
});
router.post('/collectors/:id/update', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
adminSvc.updateCollector(req.params.id, req.body);
req.session._msg = { type: 'success', text: 'Data kolektor diperbarui.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/collectors');
});
router.post('/collectors/:id/delete', requireAdminSession, restrictToAdmin, (req, res) => {
adminSvc.deleteCollector(req.params.id);
req.session._msg = { type: 'success', text: 'Kolektor berhasil dihapus.' };
res.redirect('/admin/collectors');
});
// --- AREA MANAGEMENT ---
router.get('/areas', requireAdminSession, requireSidebarMenuAccess('areas'), (req, res) => {
const areas = areaSvc.getAllAreas();
res.render('admin/areas', { title: 'Manajemen Area', company: company(), activePage: 'areas', areas, msg: flashMsg(req) });
});
router.post('/areas', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
areaSvc.createArea(req.body);
req.session._msg = { type: 'success', text: 'Area berhasil ditambahkan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/areas');
});
router.post('/areas/:id/update', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
areaSvc.updateArea(req.params.id, req.body);
req.session._msg = { type: 'success', text: 'Data area berhasil diperbarui.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/areas');
});
router.post('/areas/:id/delete', requireAdminSession, (req, res) => {
try {
areaSvc.deleteArea(req.params.id);
req.session._msg = { type: 'success', text: 'Area berhasil dihapus.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/areas');
});
router.get('/collector-payments', requireAdminSession, requireSidebarMenuAccess('collector_payments'), (req, res) => {
const status = String(req.query.status || 'pending').trim() || 'pending';
const rows = db.prepare(`
SELECT r.*,
col.name as collector_name, col.username as collector_username,
i.period_month, i.period_year, i.amount as invoice_amount, i.status as invoice_status,
c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.lat, c.lng
FROM collector_payment_requests r
JOIN collectors col ON col.id = r.collector_id
JOIN invoices i ON i.id = r.invoice_id
JOIN customers c ON c.id = r.customer_id
WHERE r.status = ?
ORDER BY r.id DESC
LIMIT 500
`).all(status);
res.render('admin/collector_payments', {
title: 'Approval Pembayaran Kolektor',
company: company(),
activePage: 'collector_payments',
status,
rows,
msg: flashMsg(req)
});
});
router.post('/collector-payments/:id/approve', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const id = Number(req.params.id || 0);
if (!Number.isFinite(id) || id <= 0) throw new Error('ID tidak valid');
const decidedNote = String(req.body.decided_note || '').trim();
const row = db.prepare(`
SELECT r.*, col.name as collector_name, col.username as collector_username
FROM collector_payment_requests r
JOIN collectors col ON col.id = r.collector_id
WHERE r.id = ?
`).get(id);
if (!row) throw new Error('Request tidak ditemukan');
if (String(row.status) !== 'pending') throw new Error('Request sudah diproses');
const inv = billingSvc.getInvoiceById(row.invoice_id);
if (!inv) throw new Error('Invoice tidak ditemukan');
if (String(inv.status) === 'paid') {
db.prepare(`
UPDATE collector_payment_requests
SET status='rejected', decided_by_role=?, decided_by_name=?, decided_note=?, decided_at=(NOW_LOCAL())
WHERE id=?
`).run(req.session.isCashier ? 'cashier' : 'admin', resolvePaidByName(req, 'Admin'), 'Invoice sudah lunas', id);
req.session._msg = { type: 'error', text: 'Invoice sudah lunas, request ditolak.' };
return res.redirect('back');
}
const collectorLabel =
(`Kolektor ${(String(row.collector_name || '').trim())}` +
(String(row.collector_username || '').trim() ? ` (@${String(row.collector_username).trim()})` : '')).trim();
const approver = resolvePaidByName(req, 'Admin');
const notesParts = [
'Via Kolektor',
collectorLabel,
`Approved oleh ${approver}`,
];
if (row.note) notesParts.push(String(row.note));
if (decidedNote) notesParts.push(`Approval: ${decidedNote}`);
const notes = notesParts.join(' | ');
billingSvc.markAsPaid(Number(row.invoice_id), collectorLabel, notes);
db.prepare(`
UPDATE collector_payment_requests
SET status='approved', decided_by_role=?, decided_by_name=?, decided_note=?, decided_at=(NOW_LOCAL())
WHERE id=?
`).run(req.session.isCashier ? 'cashier' : 'admin', approver, decidedNote, id);
const customer = customerSvc.getCustomerById(inv.customer_id);
if (customer && customer.phone) {
const pkg = customer.package_id ? customerSvc.getPackageById(customer.package_id) : null;
await sendPaymentSuccessWA(
customer.phone,
customer.name,
`${inv.period_month}/${inv.period_year}`,
Number(inv.amount || 0).toLocaleString('id-ID'),
collectorLabel,
{
customerId: customer.id,
invoiceId: inv.id,
username: customer.pppoe_username || customer.id,
customerCode: customer.customer_code || '',
packageName: pkg?.name || customer.package_name || '-',
paidAt: new Date(), method: String((req.body && req.body.payment_method) || '').trim()
}
);
}
const freshCustomer = customerSvc.getAllCustomers().find(c => Number(c.id) === Number(inv.customer_id));
if (freshCustomer && freshCustomer.status === 'suspended' && freshCustomer.unpaid_count === 0) {
await customerSvc.activateCustomer(inv.customer_id);
}
req.session._msg = { type: 'success', text: 'Request disetujui dan invoice dilunasi.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + (e.message || String(e)) };
}
res.redirect('back');
});
router.post('/collector-payments/:id/reject', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const id = Number(req.params.id || 0);
if (!Number.isFinite(id) || id <= 0) throw new Error('ID tidak valid');
const decidedNote = String(req.body.decided_note || '').trim();
const row = db.prepare(`SELECT * FROM collector_payment_requests WHERE id=?`).get(id);
if (!row) throw new Error('Request tidak ditemukan');
if (String(row.status) !== 'pending') throw new Error('Request sudah diproses');
const approver = resolvePaidByName(req, 'Admin');
db.prepare(`
UPDATE collector_payment_requests
SET status='rejected', decided_by_role=?, decided_by_name=?, decided_note=?, decided_at=(NOW_LOCAL())
WHERE id=?
`).run(req.session.isCashier ? 'cashier' : 'admin', approver, decidedNote, id);
req.session._msg = { type: 'success', text: 'Request ditolak.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + (e.message || String(e)) };
}
res.redirect('back');
});
// ─── CASHIER ATTENDANCE ──────────────────────────────────────────────────────
router.get('/cashiers/attendance', requireAdminSession, requireSidebarMenuAccess('cashier_attendance'), (req, res) => {
try {
const cashierId = req.session.cashierId || null;
const cashierName = req.session.cashierName || req.session.username || 'Kasir';
if (!cashierId) {
req.session._msg = { type: 'error', text: 'Session kasir tidak valid' };
return res.redirect('/admin');
}
const todayAttendance = attendanceSvc.getTodayAttendance('cashier', cashierId);
const history = attendanceSvc.getAttendanceHistory('cashier', cashierId, 10);
const now = getCurrentDateInTimezone();
const summary = attendanceSvc.getMonthlyAttendanceSummary(
'cashier',
cashierId,
now.getFullYear(),
now.getMonth() + 1
);
res.render('admin/cashier_attendance', {
title: 'Absensi Saya',
company: company(),
activePage: 'cashier_attendance',
session: req.session,
cashierName,
todayAttendance,
history,
summary,
msg: flashMsg(req),
t: (key, defaultVal) => defaultVal || key
});
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal memuat absensi: ' + e.message };
res.redirect('/admin');
}
});
router.post('/cashiers/attendance/checkin', requireAdminSession, uploadAttendance.single('photo'), (req, res) => {
try {
const cashierId = req.session.cashierId;
const cashierName = req.session.cashierName || req.session.username;
if (!cashierId) {
return res.json({ success: false, message: 'Session kasir tidak valid' });
}
if (!req.file) {
return res.json({ success: false, message: 'Foto check-in wajib diunggah' });
}
const today = attendanceSvc.getTodayAttendance('cashier', cashierId);
if (today) {
removeAttendanceFile(req.file);
return res.json({ success: false, message: 'Anda sudah melakukan check-in hari ini' });
}
const result = attendanceSvc.checkIn({
employee_type: 'cashier',
employee_id: cashierId,
employee_name: cashierName,
lat: req.body.lat || '',
lng: req.body.lng || '',
note: req.body.note || '',
photo: req.file ? '/uploads/attendance/' + req.file.filename : ''
});
res.json({ success: true, message: 'Check-in berhasil!', id: result.lastInsertRowid });
} catch (e) {
removeAttendanceFile(req.file);
res.json({ success: false, message: 'Gagal check-in: ' + e.message });
}
});
router.post('/cashiers/attendance/checkout', requireAdminSession, uploadAttendance.single('photo'), (req, res) => {
try {
const cashierId = req.session.cashierId;
if (!cashierId) {
return res.json({ success: false, message: 'Session kasir tidak valid' });
}
if (!req.file) {
return res.json({ success: false, message: 'Foto check-out wajib diunggah' });
}
const today = attendanceSvc.getTodayAttendance('cashier', cashierId);
if (!today) {
removeAttendanceFile(req.file);
return res.json({ success: false, message: 'Anda belum check-in hari ini' });
}
if (today.status === 'checked_out') {
removeAttendanceFile(req.file);
return res.json({ success: false, message: 'Anda sudah check-out hari ini' });
}
attendanceSvc.checkOut(today.id, {
lat: req.body.lat || '',
lng: req.body.lng || '',
note: req.body.note || '',
photo: req.file ? '/uploads/attendance/' + req.file.filename : ''
});
res.json({ success: true, message: 'Check-out berhasil!' });
} catch (e) {
removeAttendanceFile(req.file);
res.json({ success: false, message: 'Gagal check-out: ' + e.message });
}
});
router.get('/cashiers/reports', requireAdminSession, requireSidebarMenuAccess('cashiers_reports'), (req, res) => {
const allCashiers = adminSvc.getAllCashiers();
const isAdmin = Boolean(req.session?.isAdmin);
const isCashier = Boolean(req.session?.isCashier);
const requested = req.query.cashierId != null && String(req.query.cashierId).trim() !== ''
? Number(req.query.cashierId)
: null;
const cashierId =
isCashier && !isAdmin
? Number(req.session.cashierId || 0) || null
: requested;
const selectedCashier = cashierId
? (allCashiers || []).find(c => Number(c.id) === Number(cashierId)) || null
: null;
const paidByExact = selectedCashier
? (`Kasir ${(String(selectedCashier.name || '').trim())}` + (String(selectedCashier.username || '').trim() ? ` (@${String(selectedCashier.username).trim()})` : '')).trim()
: null;
const invWhere = [];
const invParams = [];
invWhere.push(`i.status='paid'`);
invWhere.push(`i.paid_by_name LIKE 'Kasir %'`);
if (paidByExact) {
invWhere.push(`i.paid_by_name = ?`);
invParams.push(paidByExact);
}
const invoiceRows = db.prepare(`
SELECT i.id as ref_id,
i.paid_at as at,
i.paid_by_name as actor_name,
i.amount as amount,
i.notes as notes,
i.period_month,
i.period_year,
c.name as customer_name,
c.phone as customer_phone,
p.name as package_name
FROM invoices i
JOIN customers c ON i.customer_id = c.id
LEFT JOIN packages p ON c.package_id = p.id
WHERE ${invWhere.join(' AND ')}
ORDER BY datetime(i.paid_at) DESC, i.id DESC
LIMIT 500
`).all(...invParams).map(r => ({
kind: 'invoice',
at: r.at,
actor_name: r.actor_name,
amount: Number(r.amount || 0),
notes: r.notes || '',
ref_id: r.ref_id,
customer_name: r.customer_name || '',
customer_phone: r.customer_phone || '',
period_month: r.period_month,
period_year: r.period_year,
package_name: r.package_name || ''
}));
const topupWhere = [];
const topupParams = [];
topupWhere.push(`t.type='topup'`);
topupWhere.push(`t.note LIKE 'Kasir %:%'`);
if (paidByExact) {
topupWhere.push(`t.note LIKE ?`);
topupParams.push(`${paidByExact}:%`);
}
const topupRows = []; /* agent dihapus */
const rows = [...invoiceRows, ...topupRows].sort((a, b) => {
const atA = a && a.at ? String(a.at) : '';
const atB = b && b.at ? String(b.at) : '';
if (atA !== atB) return atB.localeCompare(atA);
return Number(b?.ref_id || 0) - Number(a?.ref_id || 0);
}).slice(0, 800);
const invSumRow = db.prepare(`
SELECT COUNT(1) as cnt, SUM(i.amount) as total
FROM invoices i
WHERE ${invWhere.join(' AND ')}
`).get(...invParams);
const topupSumRow = { cnt: 0, total: 0 }; /* agent dihapus */
const safeCashiers = isAdmin
? allCashiers
: selectedCashier
? [selectedCashier]
: [];
res.render('admin/cashier_reports', {
title: 'Laporan Kasir',
company: company(),
activePage: 'cashiers_reports',
cashiers: safeCashiers,
cashierId: cashierId || '',
paidByExact: paidByExact || '',
rows,
summary: {
count: Number(invSumRow?.cnt || 0) + Number(topupSumRow?.cnt || 0),
total: Number(invSumRow?.total || 0) + Number(topupSumRow?.total || 0),
invoice_count: Number(invSumRow?.cnt || 0),
invoice_total: Number(invSumRow?.total || 0),
topup_count: Number(topupSumRow?.cnt || 0),
topup_total: Number(topupSumRow?.total || 0)
},
msg: flashMsg(req)
});
});
router.get('/', requireAdminSession, requireSidebarMenuAccess('dashboard'), async (req, res) => {
try {
const billing = billingSvc.getDashboardStats();
const custStats = customerSvc.getCustomerStats();
const settings = getSettings(); // Get current settings
const chartYear = getCurrentTimeInfo().year;
const revenueMonthly = billingSvc.getMonthlyRevenue(chartYear);
const todayRevenue = billingSvc.getTodayRevenue();
res.render('admin/dashboard', {
title: 'Dashboard', company: company(), version: '2.0.0',
activePage: 'dashboard', billing, custStats, settings, revenueMonthly, todayRevenue, chartYear
});
} catch (e) {
logger.error('Admin dashboard error:', e);
res.status(500).send('Error loading dashboard: ' + e.message);
}
});
// ─── DEVICE ROUTES (existing) ───────────────────────────────────────────────
router.get('/devices', requireAdminSession, (req, res) => {
const settings = getSettings();
res.render('admin/dashboard', { title: 'Monitoring ONU', company: company(), version: '2.0.0', activePage: 'devices', billing: null, custStats: null, settings });
});
router.get('/bulk', requireAdminSession, (req, res) => {
const settings = getSettings();
res.render('admin/dashboard', { title: 'Konfigurasi Massal', company: company(), version: '2.0.0', activePage: 'bulk', billing: null, custStats: null, settings });
});
// ─── ONU MODEM STICKER PRINT ───────────────────────────────────────────────
router.get('/onu-stickers', requireAdminSession, requireSidebarMenuAccess('onu_stickers'), async (req, res) => {
try {
const settings = getSettings();
const mode = req.query.mode === 'customers' ? 'customers' : 'blank';
const count = Math.max(1, Math.min(100, Number(req.query.count || 10)));
const searchQuery = String(req.query.q || '').trim();
const selectedRouterId = req.selectedRouterId || (req.query.router_id ? Number(req.query.router_id) : null);
const csPhone = req.query.cs_phone || settings.company_phone || settings.company_whatsapp || '08xxxxxxxxxx';
const serverBaseUrl = `${req.protocol}://${req.get('host')}`;
const routers = mikrotikService.getAllRouters();
let stickers = [];
if (mode === 'blank') {
const qrPayload = `${serverBaseUrl}/app/connect`;
const qrCodeDataUrl = await QRCode.toDataURL(qrPayload, {
margin: 1,
width: 250,
errorCorrectionLevel: 'M'
});
for (let i = 0; i < count; i++) {
stickers.push({
name: '',
id_display: '',
package_name: '',
qrCodeDataUrl
});
}
} else {
const customers = customerSvc.getAllCustomers(searchQuery, selectedRouterId, 'active');
const targetCusts = (customers || []).slice(0, 100);
for (const c of targetCusts) {
const qrPayload = `${serverBaseUrl}/app/connect?cid=${encodeURIComponent(c.pppoe_username || c.id)}`;
const qrCodeDataUrl = await QRCode.toDataURL(qrPayload, {
margin: 1,
width: 250,
errorCorrectionLevel: 'M'
});
stickers.push({
name: c.name,
id_display: `${c.pppoe_username || ('ID:' + c.id)}`,
package_name: c.package_name || 'Reguler',
qrCodeDataUrl
});
}
}
res.render('admin/onu_stickers', {
company: company(),
companyLogo: settings.company_logo || '',
csPhone,
mode,
count,
searchQuery,
selectedRouterId,
routers,
stickers
});
} catch (e) {
logger.error(`[ONU Stickers] Error: ${e.message}`);
res.status(500).send(`Gagal memuat stiker: ${e.message}`);
}
});
router.get('/customers/:id/sticker-onu', requireAdminSession, async (req, res) => {
try {
const settings = getSettings();
const customer = customerSvc.getCustomerById(req.params.id);
if (!customer) return res.status(404).send('Pelanggan tidak ditemukan');
const csPhone = settings.company_phone || settings.company_whatsapp || '08xxxxxxxxxx';
const serverBaseUrl = `${req.protocol}://${req.get('host')}`;
const qrPayload = `${serverBaseUrl}/app/connect?cid=${encodeURIComponent(customer.pppoe_username || customer.id)}`;
const qrCodeDataUrl = await QRCode.toDataURL(qrPayload, {
margin: 1,
width: 250,
errorCorrectionLevel: 'M'
});
const stickers = [{
name: customer.name,
id_display: `${customer.pppoe_username || ('ID:' + customer.id)}`,
package_name: customer.package_name || 'Reguler',
qrCodeDataUrl
}];
res.render('admin/onu_stickers', {
company: company(),
companyLogo: settings.company_logo || '',
csPhone,
mode: 'customers',
count: 1,
searchQuery: '',
selectedRouterId: null,
routers: [],
stickers
});
} catch (e) {
res.status(500).send(`Gagal memuat stiker: ${e.message}`);
}
});
// ─── CUSTOMERS ─────────────────────────────────────────────────────────────
router.get('/customers', requireAdminSession, requireSidebarMenuAccess('customers'), async (req, res) => {
const { search = '', status: filterStatus = '', area: filterArea = '',
package_id: filterPackage = '', collector_id: filterCollector = '', olt_id: filterOlt = '',
connection_type: filterConn = '', billing: filterBilling = '', auto_isolate: filterAuto = '' } = req.query;
const selectedRouterId = req.selectedRouterId || (req.query.router_id ? Number(req.query.router_id) : null);
const customers = customerSvc.getAllCustomers(search, selectedRouterId, filterStatus, filterArea, {
packageId: filterPackage, collectorId: filterCollector, oltId: filterOlt,
connectionType: filterConn, billing: filterBilling, autoIsolate: filterAuto
});
const stats = customerSvc.getCustomerStats();
const packages = customerSvc.getAllPackages(selectedRouterId);
const routers = mikrotikService.getAllRouters();
const olts = oltSvc.getAllOlts();
const odps = odpSvc.getAllOdps();
const collectors = adminSvc.getAllCollectors();
const areas = customerSvc.getAllCustomerAreas();
const masterAreas = areaSvc.getAllAreas();
let activeSessionsMap = new Map();
try {
activeSessionsMap = await mikrotikService.getAllActiveSessionsMap();
} catch (e) {
logger.warn('[Customers] Failed to fetch active sessions map:', e.message);
}
res.render('admin/customers', {
title: 'Data Pelanggan', company: company(), activePage: 'customers',
customers, stats, packages, routers, olts, odps, collectors, areas, masterAreas, activeSessionsMap, search, filterStatus, filterArea, selectedRouterId,
filterPackage, filterCollector, filterOlt, filterConn, filterBilling, filterAuto, msg: flashMsg(req),
settings: getSettings()
});
});
router.post('/customers', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const connectionType = String(req.body.connection_type || 'pppoe').trim().toLowerCase() || 'pppoe';
req.body.connection_type = connectionType;
if (connectionType !== 'pppoe') req.body.pppoe_username = '';
if (connectionType !== 'static') req.body.static_ip = '';
if (connectionType !== 'hotspot') {
req.body.hotspot_username = '';
req.body.hotspot_password = '';
req.body.hotspot_profile = '';
}
const multiRouterMode = getSetting('multi_router_mode', 'disabled') === 'active';
const defaultRouterId = multiRouterMode ? null : getSetting('default_router_id', null);
if (connectionType === 'pppoe') {
const routerId = req.body.router_id ? Number(req.body.router_id) : null;
const username = String(req.body.pppoe_username || '').trim();
const password = String(req.body.pppoe_password || '').trim();
const remoteAddress = String(req.body.pppoe_remote_address || '').trim();
req.body.pppoe_username = username;
req.body.pppoe_password = password;
req.body.pppoe_remote_address = remoteAddress;
if (!username) throw new Error('PPPoE Username tidak boleh kosong');
// Router validation - required only if multi-router mode is active
const effectiveRouterId = multiRouterMode ? routerId : (routerId || customerSvc.getEffectiveRouterId(null));
if (!effectiveRouterId || effectiveRouterId <= 0) {
if (multiRouterMode) {
throw new Error('Router MikroTik WAJIB dipilih untuk koneksi PPPoE (fungsi isolir & profile management membutuhkan router)');
} else {
logger.warn('[Customer] Router MikroTik tidak tersedia; lanjut simpan data tanpa validasi router.');
}
}
const existing = db.prepare('SELECT id, name FROM customers WHERE router_id IS ? AND pppoe_username = ? LIMIT 1').get(effectiveRouterId, username);
if (existing) throw new Error(`PPPoE Username sudah dipakai pelanggan lain: ${existing.name}`);
// Only validate against MikroTik if password is not provided (meaning it's from MikroTik list)
if (!password) {
try {
const conn = await mikrotikService.getConnection(effectiveRouterId);
try {
const results = await conn.client.menu('/ppp/secret')
.where('service', 'pppoe')
.where('name', username)
.get();
if (!Array.isArray(results) || results.length === 0) logger.warn('[Customer] PPPoE username belum ada di MikroTik (lanjut simpan DB).');
} finally { if (conn && conn.api) conn.api.close(); }
} catch (mErr) {
logger.warn('[Customer] Validasi PPPoE ke MikroTik dilewati: ' + mErr.message);
}
}
// Set effective router ID
req.body.router_id = effectiveRouterId;
}
if (connectionType === 'static') {
const routerId = req.body.router_id ? Number(req.body.router_id) : null;
const staticIp = String(req.body.static_ip || '').trim();
req.body.static_ip = staticIp;
if (!staticIp) throw new Error('Static IP tidak boleh kosong');
// Router validation - required only if multi-router mode is active
const effectiveRouterId = multiRouterMode ? routerId : (routerId || customerSvc.getEffectiveRouterId(null));
if (!effectiveRouterId || effectiveRouterId <= 0) {
if (multiRouterMode) {
throw new Error('Router MikroTik WAJIB dipilih untuk koneksi Static IP (untuk management & isolir)');
} else {
logger.warn('[Customer] Router MikroTik tidak tersedia; lanjut simpan data tanpa validasi router.');
}
}
const existing = db.prepare('SELECT id, name FROM customers WHERE router_id IS ? AND static_ip = ? LIMIT 1').get(effectiveRouterId, staticIp);
if (existing) throw new Error(`Static IP sudah dipakai pelanggan lain: ${existing.name}`);
// Set effective router ID
req.body.router_id = effectiveRouterId;
}
if (connectionType === 'hotspot') {
const routerId = req.body.router_id ? Number(req.body.router_id) : null;
const username = String(req.body.hotspot_username || '').trim();
req.body.hotspot_username = username;
if (!username) throw new Error('Hotspot Username tidak boleh kosong');
// Router validation - required only if multi-router mode is active
const effectiveRouterId = multiRouterMode ? routerId : (routerId || customerSvc.getEffectiveRouterId(null));
if (!effectiveRouterId || effectiveRouterId <= 0) {
if (multiRouterMode) {
throw new Error('Router MikroTik WAJIB dipilih untuk koneksi Hotspot (untuk user management & isolir)');
} else {
logger.warn('[Customer] Router MikroTik tidak tersedia; lanjut simpan data tanpa validasi router.');
}
}
const existing = db.prepare('SELECT id, name FROM customers WHERE router_id IS ? AND hotspot_username = ? LIMIT 1').get(effectiveRouterId, username);
if (existing) throw new Error(`Hotspot Username sudah dipakai pelanggan lain: ${existing.name}`);
const password = String(req.body.hotspot_password || '').trim() || username;
req.body.hotspot_password = password;
let profile = String(req.body.hotspot_profile || '').trim();
if (!profile && req.body.package_id) {
const pkg = customerSvc.getPackageById(req.body.package_id);
if (pkg) profile = String(pkg.name || '').trim();
}
req.body.hotspot_profile = profile;
if (!profile) throw new Error('Hotspot User Profile tidak boleh kosong');
try {
const profs = await mikrotikService.getHotspotUserProfiles(effectiveRouterId);
const ok = Array.isArray(profs) && profs.some(p => String(p?.name || '').trim() === profile);
if (!ok) logger.warn('[Customer] Hotspot profile ' + profile + ' belum ada di MikroTik (lanjut simpan DB).');
} catch (mErr) {
logger.warn('[Customer] Validasi Hotspot ke MikroTik dilewati: ' + mErr.message);
}
// Set effective router ID
req.body.router_id = effectiveRouterId;
}
const radiusEnabled = getSetting('radius_enabled', '0') === '1';
// Ketika RADIUS offline, paksa is_radius = 0 (MikroTik mode) agar secret SELALU dibuat ke MikroTik
const isRadius = radiusEnabled ? (req.body.is_radius !== undefined ? (Number(req.body.is_radius) === 1 ? 1 : 0) : 0) : 0;
req.body.is_radius = isRadius;
customerSvc.createCustomer(req.body);
// ========================================================================
// SYNC KE MIKROTIK - PENTING: SECRET TIDAK PERNAH DIHAPUS!
// ========================================================================
// Logika:
// - Jika RADIUS OFFLINE: SELALU create/update secret ke MikroTik
// - Jika RADIUS AKTIF tapi is_radius=0: Hybrid mode, tetap ke MikroTik
// - Jika RADIUS AKTIF dan is_radius=1: Full RADIUS mode, skip MikroTik
//
// CATATAN: Kode ini TIDAK PERNAH menghapus secret dari MikroTik!
// Hanya CREATE (jika belum ada) atau UPDATE PROFILE (jika sudah ada)
// ========================================================================
const shouldSyncToMikrotik = !radiusEnabled || !isRadius;
if (connectionType === 'pppoe' && req.body.pppoe_username && shouldSyncToMikrotik) {
const password = String(req.body.pppoe_password || '').trim();
const remoteAddress = String(req.body.pppoe_remote_address || '').trim();
// If manual input (password provided), create PPPoE secret in MikroTik
if (password) {
let targetProfile = '';
if (req.body.status === 'suspended') {
targetProfile = req.body.isolir_profile || 'isolir';
} else if (req.body.package_id) {
const pkg = customerSvc.getPackageById(req.body.package_id);
if (pkg) targetProfile = pkg.name;
}
if (targetProfile) {
try {
await mikrotikService.createPppoeSecret({
username: req.body.pppoe_username,
password: password,
profile: targetProfile,
remoteAddress: remoteAddress,
routerId: req.body.router_id
});
logger.info(`[Add Customer] Created PPPoE secret "${req.body.pppoe_username}" in MikroTik (RADIUS ${radiusEnabled ? 'ON' : 'OFF'})`);
} catch (mErr) {
logger.error('Mikrotik create PPPoE secret error:', mErr);
}
}
} else {
// If from MikroTik list (no password input), just update profile
let targetProfile = '';
if (req.body.status === 'suspended') {
targetProfile = req.body.isolir_profile || 'isolir';
} else if (req.body.package_id) {
const pkg = customerSvc.getPackageById(req.body.package_id);
if (pkg) targetProfile = pkg.name;
}
if (targetProfile) {
try {
await mikrotikService.setPppoeProfile(req.body.pppoe_username, targetProfile, req.body.router_id);
logger.info(`[Add Customer] Updated PPPoE profile for "${req.body.pppoe_username}" to "${targetProfile}"`);
} catch (mErr) {
logger.error('Mikrotik sync error (create):', mErr);
}
}
}
}
if (connectionType === 'hotspot' && req.body.hotspot_username) {
const disabled = String(req.body.status || 'active').toLowerCase() !== 'active';
try {
await mikrotikService.upsertHotspotUser({
username: String(req.body.hotspot_username || '').trim(),
password: String(req.body.hotspot_password || '').trim(),
profile: String(req.body.hotspot_profile || '').trim(),
macAddress: String(req.body.mac_address || '').trim(),
disabled
}, req.body.router_id ? Number(req.body.router_id) : null);
} catch (mErr) {
console.error('Mikrotik sync error (create hotspot):', mErr);
}
}
req.session._msg = { type: 'success', text: `Pelanggan "${req.body.name}" berhasil ditambahkan.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal menambahkan pelanggan: ' + e.message };
}
res.redirect('/admin/customers');
});
router.post('/customers/:id/update', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const customerId = Number(req.params.id);
const connectionType = String(req.body.connection_type || 'pppoe').trim().toLowerCase() || 'pppoe';
req.body.connection_type = connectionType;
if (connectionType !== 'pppoe') req.body.pppoe_username = '';
if (connectionType !== 'static') req.body.static_ip = '';
if (connectionType !== 'hotspot') {
req.body.hotspot_username = '';
req.body.hotspot_password = '';
req.body.hotspot_profile = '';
}
const multiRouterMode = getSetting('multi_router_mode', 'disabled') === 'active';
const defaultRouterId = multiRouterMode ? null : getSetting('default_router_id', null);
if (connectionType === 'pppoe') {
const routerId = req.body.router_id ? Number(req.body.router_id) : null;
const username = String(req.body.pppoe_username || '').trim();
req.body.pppoe_username = username;
if (!username) throw new Error('PPPoE Username tidak boleh kosong');
// Router validation - required only if multi-router mode is active
const effectiveRouterId = multiRouterMode ? routerId : (routerId || customerSvc.getEffectiveRouterId(null));
if (!effectiveRouterId || effectiveRouterId <= 0) {
if (multiRouterMode) {
throw new Error('Router MikroTik WAJIB dipilih untuk koneksi PPPoE (fungsi isolir & profile management membutuhkan router)');
} else {
logger.warn('[Customer] Router MikroTik tidak tersedia; lanjut simpan data tanpa validasi router.');
}
}
const existing = db.prepare('SELECT id, name FROM customers WHERE router_id IS ? AND pppoe_username = ? AND id != ? LIMIT 1').get(effectiveRouterId, username, customerId);
if (existing) throw new Error(`PPPoE Username sudah dipakai pelanggan lain: ${existing.name}`);
try {
const conn = await mikrotikService.getConnection(effectiveRouterId);
try {
const results = await conn.client.menu('/ppp/secret')
.where('service', 'pppoe')
.where('name', username)
.get();
if (!Array.isArray(results) || results.length === 0) logger.warn('[Customer] PPPoE username belum ada di MikroTik (lanjut simpan DB).');
} finally { if (conn && conn.api) conn.api.close(); }
} catch (mErr) {
logger.warn('[Customer] Validasi PPPoE ke MikroTik dilewati: ' + mErr.message);
}
// Set effective router ID
req.body.router_id = effectiveRouterId;
}
if (connectionType === 'static') {
const routerId = req.body.router_id ? Number(req.body.router_id) : null;
const staticIp = String(req.body.static_ip || '').trim();
req.body.static_ip = staticIp;
if (!staticIp) throw new Error('Static IP tidak boleh kosong');
// Router validation - required only if multi-router mode is active
const effectiveRouterId = multiRouterMode ? routerId : (routerId || customerSvc.getEffectiveRouterId(null));
if (!effectiveRouterId || effectiveRouterId <= 0) {
if (multiRouterMode) {
throw new Error('Router MikroTik WAJIB dipilih untuk koneksi Static IP (untuk management & isolir)');
} else {
logger.warn('[Customer] Router MikroTik tidak tersedia; lanjut simpan data tanpa validasi router.');
}
}
const existing = db.prepare('SELECT id, name FROM customers WHERE router_id IS ? AND static_ip = ? AND id != ? LIMIT 1').get(effectiveRouterId, staticIp, customerId);
if (existing) throw new Error(`Static IP sudah dipakai pelanggan lain: ${existing.name}`);
// Set effective router ID
req.body.router_id = effectiveRouterId;
}
if (connectionType === 'hotspot') {
const routerId = req.body.router_id ? Number(req.body.router_id) : null;
const username = String(req.body.hotspot_username || '').trim();
req.body.hotspot_username = username;
if (!username) throw new Error('Hotspot Username tidak boleh kosong');
// Router validation - required only if multi-router mode is active
const effectiveRouterId = multiRouterMode ? routerId : (routerId || customerSvc.getEffectiveRouterId(null));
if (!effectiveRouterId || effectiveRouterId <= 0) {
if (multiRouterMode) {
throw new Error('Router MikroTik WAJIB dipilih untuk koneksi Hotspot (untuk user management & isolir)');
} else {
logger.warn('[Customer] Router MikroTik tidak tersedia; lanjut simpan data tanpa validasi router.');
}
}
const existing = db.prepare('SELECT id, name FROM customers WHERE router_id IS ? AND hotspot_username = ? AND id != ? LIMIT 1').get(effectiveRouterId, username, customerId);
if (existing) throw new Error(`Hotspot Username sudah dipakai pelanggan lain: ${existing.name}`);
const password = String(req.body.hotspot_password || '').trim() || username;
req.body.hotspot_password = password;
let profile = String(req.body.hotspot_profile || '').trim();
if (!profile && req.body.package_id) {
const pkg = customerSvc.getPackageById(req.body.package_id);
if (pkg) profile = String(pkg.name || '').trim();
}
req.body.hotspot_profile = profile;
if (!profile) throw new Error('Hotspot User Profile tidak boleh kosong');
try {
const profs = await mikrotikService.getHotspotUserProfiles(effectiveRouterId);
const ok = Array.isArray(profs) && profs.some(p => String(p?.name || '').trim() === profile);
if (!ok) logger.warn('[Customer] Hotspot profile ' + profile + ' belum ada di MikroTik (lanjut simpan DB).');
} catch (mErr) {
logger.warn('[Customer] Validasi Hotspot ke MikroTik dilewati: ' + mErr.message);
}
// Set effective router ID
req.body.router_id = effectiveRouterId;
}
const radiusEnabled = getSetting('radius_enabled', '0') === '1';
// Ketika RADIUS offline, paksa is_radius = 0 (MikroTik mode) agar secret SELALU ada di MikroTik
const isRadius = radiusEnabled ? (req.body.is_radius !== undefined ? (Number(req.body.is_radius) === 1 ? 1 : 0) : 0) : 0;
req.body.is_radius = isRadius;
// Get old customer data to detect username changes
const oldCustomer = customerSvc.getCustomerById(customerId);
customerSvc.updateCustomer(req.params.id, req.body);
// ========================================================================
// SYNC KE MIKROTIK SAAT EDIT - PENTING: SECRET TIDAK PERNAH DIHAPUS!
// ========================================================================
// Logika:
// - Jika RADIUS OFFLINE: SELALU sync ke MikroTik
// - Jika RADIUS AKTIF tapi is_radius=0: Hybrid mode, tetap sync ke MikroTik
// - Jika RADIUS AKTIF dan is_radius=1: Full RADIUS mode, skip MikroTik
//
// Proses:
// 1. Cek apakah secret sudah ada di MikroTik
// 2. Jika sudah ada: UPDATE PROFILE saja (TIDAK HAPUS!)
// 3. Jika belum ada: CREATE secret baru (jika ada password)
//
// CATATAN: Kode ini TIDAK PERNAH menghapus secret dari MikroTik!
// ========================================================================
const shouldSyncToMikrotik = !radiusEnabled || !isRadius;
if (connectionType === 'pppoe' && req.body.pppoe_username && shouldSyncToMikrotik) {
try {
const newUsername = String(req.body.pppoe_username || '').trim();
const newPassword = String(req.body.pppoe_password || '').trim();
const remoteAddress = String(req.body.pppoe_remote_address || '').trim();
let targetProfile = '';
if (req.body.status === 'suspended') {
targetProfile = req.body.isolir_profile || 'isolir';
} else if (req.body.package_id) {
const pkg = customerSvc.getPackageById(req.body.package_id);
if (pkg) targetProfile = pkg.name;
}
if (targetProfile) {
try {
// Cek apakah secret sudah ada di MikroTik
const secrets = await mikrotikService.getPppoeSecrets(req.body.router_id);
const existingSecret = secrets.find(s => String(s.name || '').trim() === newUsername);
if (existingSecret) {
// Secret sudah ada, hanya update profile
await mikrotikService.setPppoeProfile(newUsername, targetProfile, req.body.router_id);
logger.info(`[Edit Customer] Updated PPPoE profile for "${newUsername}" to "${targetProfile}"`);
} else if (newPassword) {
// Secret belum ada DAN ada password, create secret baru ke MikroTik
await mikrotikService.createPppoeSecret({
username: newUsername,
password: newPassword,
profile: targetProfile,
remoteAddress: remoteAddress,
routerId: req.body.router_id
});
logger.info(`[Edit Customer] Created NEW PPPoE secret for "${newUsername}" in MikroTik`);
} else {
logger.warn(`[Edit Customer] Cannot create PPPoE secret for "${newUsername}" - password not provided`);
}
} catch (mErr) {
logger.error('Mikrotik sync error (update PPPoE):', mErr.message);
}
}
} catch (syncErr) {
logger.warn(`[Update] MikroTik API sync skipped/failed for customer ${customerId}: ${syncErr.message}`);
}
}
if (connectionType === 'hotspot' && req.body.hotspot_username) {
try {
const oldUsername = oldCustomer ? String(oldCustomer.hotspot_username || '').trim() : '';
const newUsername = String(req.body.hotspot_username || '').trim();
const oldRouterIdForOldUser = oldCustomer ? oldCustomer.router_id : null;
const oldEffectiveRouterId = oldCustomer ? customerSvc.getEffectiveRouterId(oldRouterIdForOldUser) : null;
// If username changed, handle old one first
if (oldUsername && oldUsername !== newUsername && oldEffectiveRouterId) {
try {
logger.info(`[Update] Hotspot username changed: "${oldUsername}" → "${newUsername}" for customer ${customerId}`);
const oldUser = await mikrotikService.getHotspotUserByName(oldUsername, oldEffectiveRouterId);
if (oldUser && (oldUser.id || oldUser['.id'])) {
const userId = oldUser['.id'] || oldUser.id;
await mikrotikService.deleteHotspotUser(userId, oldEffectiveRouterId);
logger.info(`[Update] Deleted old Hotspot user: ${oldUsername} from router ${oldEffectiveRouterId}`);
}
} catch (err) {
logger.warn(`[Update] Failed to delete old Hotspot user ${oldUsername}: ${err.message}`);
}
}
// Now handle new username
const disabled = String(req.body.status || 'active').toLowerCase() !== 'active';
await mikrotikService.upsertHotspotUser({
username: newUsername,
password: String(req.body.hotspot_password || '').trim(),
profile: String(req.body.hotspot_profile || '').trim(),
macAddress: String(req.body.mac_address || '').trim(),
disabled
}, req.body.router_id ? Number(req.body.router_id) : null);
} catch (mErr) {
logger.warn(`[Update] Mikrotik sync error (update hotspot): ${mErr.message}`);
}
}
req.session._msg = { type: 'success', text: 'Data pelanggan berhasil diperbarui.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal memperbarui: ' + e.message };
}
res.redirect('/admin/customers');
});
async function syncCustomerToMikrotik(customerId) {
const c = customerSvc.getCustomerById(customerId);
if (!c) return { ok: false, message: 'Pelanggan tidak ditemukan' };
const routerId = customerSvc.getEffectiveRouterId(c.router_id);
// ── Mode RADIUS: kredensial/paket dibaca langsung dari DB ──
// Perubahan dari billing dipakai saat autentikasi berikutnya. Agar langsung,
// kick sesi PPPoE sekarang supaya pelanggan redial (muat rate-limit/IP/paket baru).
if (String(getSetting('radius_enabled', '0')) === '1') {
const uname = String(c.pppoe_username || '').trim();
if (!uname) return { ok: false, message: 'PPPoE username kosong' };
try { await mikrotikService.kickPppoeUser(uname, routerId); }
catch (e) { return { ok: false, message: 'Gagal kick sesi RADIUS: ' + e.message }; }
try { db.prepare("UPDATE radius_accounting SET status_type=2, updated_at=NOW_LOCAL() WHERE username=? AND status_type IN (1,3)").run(uname); } catch (e) {}
return { ok: true, message: 'Mode RADIUS: perubahan billing diterapkan saat redial. Sesi "' + uname + '" di-kick agar memuat ulang (rate-limit/IP/paket).' };
}
if (!routerId || routerId <= 0) return { ok: false, message: 'Router MikroTik tidak tersedia untuk pelanggan ini' };
const connType = String(c.connection_type || 'pppoe').toLowerCase();
const pkg = c.package_id ? customerSvc.getPackageById(c.package_id) : null;
const isSuspended = String(c.status || '').toLowerCase() !== 'active';
if (connType === 'pppoe') {
const username = String(c.pppoe_username || '').trim();
if (!username) return { ok: false, message: 'PPPoE username kosong' };
const targetProfile = isSuspended ? ((pkg && pkg.isolir_profile) || c.isolir_profile || getSetting('mikrotik_isolir_profile', 'isolir') || 'isolir') : (pkg ? pkg.name : 'default');
const secrets = await mikrotikService.getPppoeSecrets(routerId);
const exists = Array.isArray(secrets) && secrets.some(function (s) { return String(s.name || '').trim() === username; });
if (exists) {
await mikrotikService.setPppoeProfile(username, targetProfile, routerId);
return { ok: true, message: 'Profil PPPoE "' + username + '" disinkronkan ke "' + targetProfile + '".' };
}
const pass = String(c.pppoe_password || '').trim();
if (!pass) return { ok: false, message: 'Secret "' + username + '" tidak ada di MikroTik & password kosong (tidak bisa dibuat).' };
await mikrotikService.createPppoeSecret({ username: username, password: pass, profile: targetProfile, remoteAddress: String(c.pppoe_remote_address || '').trim(), routerId: routerId });
return { ok: true, message: 'Secret PPPoE "' + username + '" dibuat di MikroTik (profil "' + targetProfile + '").' };
}
if (connType === 'hotspot') {
const username = String(c.hotspot_username || '').trim();
if (!username) return { ok: false, message: 'Hotspot username kosong' };
const profile = String(c.hotspot_profile || '').trim() || (pkg ? String(pkg.name || '') : '');
await mikrotikService.upsertHotspotUser({ username: username, password: String(c.hotspot_password || '').trim(), profile: profile, macAddress: String(c.mac_address || '').trim(), disabled: isSuspended }, routerId);
return { ok: true, message: 'Hotspot user "' + username + '" disinkronkan (' + (isSuspended ? 'disabled' : 'enabled') + ').' };
}
if (connType === 'static') {
const ip = String(c.static_ip || '').trim();
if (!ip) return { ok: false, message: 'Static IP kosong' };
let limit = '5M/5M';
if (pkg) {
const up = Number(pkg.speed_up || 0) || 0;
const down = Number(pkg.speed_down || 0) || 0;
const upM = up > 0 ? Math.max(1, Math.round(up / 1000)) : 5;
const dnM = down > 0 ? Math.max(1, Math.round(down / 1000)) : 5;
limit = upM + 'M/' + dnM + 'M';
}
await mikrotikService.manageStaticIp({ ip: ip, name: c.name, limit: limit, isolate: isSuspended }, routerId);
return { ok: true, message: 'Static IP ' + ip + ' disinkronkan (' + (isSuspended ? 'isolir' : 'normal') + ').' };
}
return { ok: false, message: 'Tipe koneksi tidak dikenal' };
}
router.post('/customers/sync-all-mikrotik', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const list = db.prepare("SELECT id FROM customers WHERE status IN ('active','suspended') ORDER BY id").all();
res.json({ ok: true, message: 'Sinkronisasi ' + list.length + ' pelanggan dijalankan di latar belakang. Cek log untuk hasilnya.' });
(async function () {
let ok = 0, fail = 0;
for (const row of list) {
try { const r = await syncCustomerToMikrotik(row.id); if (r.ok) ok++; else fail++; } catch (e) { fail++; }
await new Promise(function (r) { setTimeout(r, 150); });
}
logger.info('[SyncMikroTik] Selesai: ok=' + ok + ' fail=' + fail + ' dari ' + list.length + '.');
})().catch(function () {});
} catch (e) { return res.status(200).json({ ok: false, message: e.message }); }
});
router.post('/customers/:id/sync-mikrotik', requireAdminSession, async (req, res) => {
try { const r = await syncCustomerToMikrotik(req.params.id); return res.json(r); }
catch (e) { return res.json({ ok: false, message: 'Gagal sync: ' + e.message }); }
});
router.post('/customers/:id/generate-pppoe', requireAdminSession, async (req, res) => {
try {
const g = customerSvc.generatePppoeAccount(req.params.id);
const cust = customerSvc.getCustomerById(req.params.id);
try {
const waNotify = require('../services/waNotifyService');
const pkgC = cust && cust.package_id ? customerSvc.getPackageById(cust.package_id) : null;
const credMsg = '🔑 *KREDENSIAL PPPoE PELANGGAN*\n\n' +
'👤 *Nama:* ' + ((cust && cust.name) || '-') + '\n' +
'🆔 *ID:* ' + ((cust && cust.customer_code) || '-') + '\n' +
'📦 *Paket:* ' + (pkgC ? pkgC.name : ((cust && cust.package_name) || '-')) + '\n' +
'📡 *PPPoE User:* *' + g.username + '*\n' +
'🔒 *PPPoE Pass:* *' + g.password + '*\n' +
'📍 *Alamat:* ' + ((cust && cust.address) || '-') + '\n\n' +
'Silakan konfigurasi perangkat pelanggan dengan data di atas.';
await waNotify.notifyAdmin(credMsg);
} catch (e) {}
req.session._msg = { type: 'success', text: 'Akun PPPoE dibuat: ' + g.username + ' / ' + g.password + ' (kredensial dikirim ke admin & teknisi).' };
} catch (e) { req.session._msg = { type: 'error', text: 'Gagal generate PPPoE: ' + e.message }; }
res.redirect(req.get('Referrer') || '/admin/customers');
});
router.post('/customers/delete-bulk', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const { customer_ids } = req.body;
const ids = Array.isArray(customer_ids) ? customer_ids : [customer_ids];
const clean = ids
.map(x => Number(x))
.filter(n => Number.isFinite(n) && n > 0);
if (!clean || clean.length === 0) throw new Error('Tidak ada pelanggan yang dipilih.');
const deleted = await customerSvc.bulkDeleteCustomers(clean);
req.session._msg = { type: 'success', text: `${deleted} data pelanggan berhasil dihapus (Akun PPPoE di MikroTik tetap aman).` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal hapus massal: ' + e.message };
}
res.redirect('/admin/customers');
});
router.post('/customers/:id/delete', requireAdminSession, async (req, res) => {
try {
const cust = customerSvc.getCustomerById(req.params.id);
await customerSvc.deleteCustomer(req.params.id);
try {
require('../services/auditTrailService').logAuditTrail({
action: 'DELETE_CUSTOMER',
entity_type: 'customer',
entity_id: String(req.params.id),
actor_type: 'admin',
actor_id: req.session.adminUser || null,
actor_name: req.session.adminUser || 'Admin',
details: cust ? { name: cust.name, customer_code: cust.customer_code, phone: cust.phone, status: cust.status } : {},
ip_address: req.ip,
user_agent: req.headers['user-agent']
});
} catch (e) {}
logger.info(`[Customer] Dihapus oleh ${req.session.adminUser || 'Admin'}: id=${req.params.id} ${cust ? cust.name : ''}`);
req.session._msg = { type: 'success', text: 'Pelanggan berhasil dihapus.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/customers');
});
router.post('/customers/:id/disconnect', requireAdminSession, async (req, res) => {
try {
const custId = Number(req.params.id);
const customer = customerSvc.getCustomerById(custId);
if (!customer) throw new Error('Pelanggan tidak ditemukan');
const pppoeUser = String(customer.pppoe_username || '').trim();
const hotspotUser = String(customer.hotspot_username || '').trim();
const username = pppoeUser || hotspotUser || String(customer.name || '').trim();
let kicked = false;
if (pppoeUser) {
kicked = await mikrotikService.kickPppoeUser(pppoeUser, customer.router_id);
} else if (hotspotUser) {
kicked = await mikrotikService.kickHotspotUser(hotspotUser, customer.router_id);
} else if (username) {
kicked = await mikrotikService.kickPppoeUser(username, customer.router_id) || await mikrotikService.kickHotspotUser(username, customer.router_id);
}
if (kicked) {
req.session._msg = { type: 'success', text: `Sesi koneksi aktif untuk "${customer.name}" berhasil diputus dari MikroTik.` };
} else {
req.session._msg = { type: 'error', text: `Sesi aktif untuk "${customer.name}" tidak ditemukan di MikroTik atau gagal diputus.` };
}
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal memutus koneksi: ' + (e.message || String(e)) };
}
res.redirect('/admin/customers');
});
// ─── EXPORT/IMPORT CUSTOMERS ──────────────────────────────────────
router.get('/customers/export', requireAdminSession, (req, res) => {
try {
const customers = customerSvc.getAllCustomers();
const data = customers.map(c => ({
'ID': c.id,
'NIK': c.nik || '',
'Nama': c.name,
'Telepon': c.phone,
'Email': c.email || '',
'Alamat': c.address,
'Area': c.area || '',
'Paket': c.package_name || '-',
'Router': c.router_name || '-',
'Tipe Koneksi': c.connection_type || 'pppoe',
'Tag ONU': c.genieacs_tag,
'PPPoE Username': c.pppoe_username,
'Hotspot Username': c.hotspot_username || '',
'Static IP': c.static_ip || '',
'Isolir Profile': c.isolir_profile,
'Status': c.status,
'Tanggal Pasang': c.install_date,
'Auto Isolir': c.auto_isolate === 1 ? 'YA' : 'TIDAK',
'Tgl Isolir': c.isolate_day,
'ODP': c.odp_name || '-',
'Latitude': c.lat || '',
'Longitude': c.lng || '',
'Catatan': c.notes
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Pelanggan');
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
res.setHeader('Content-Disposition', 'attachment; filename=daftar_pelanggan.xlsx');
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.send(buf);
} catch (e) {
logger.error('Export error:', e);
res.status(500).send('Gagal export data.');
}
});
router.post('/customers/import', requireAdminSession, upload.single('file'), async (req, res) => {
try {
if (!req.file) throw new Error('File tidak ditemukan');
const wb = XLSX.read(req.file.buffer, { type: 'buffer' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(ws);
logger.info(`[Import] Found ${rows.length} rows in Excel file.`);
const packages = customerSvc.getAllPackages();
const odps = odpSvc.getAllOdps();
const routers = mikrotikService.getAllRouters();
let count = 0;
for (let row of rows) {
// Normalize row keys (trim whitespace)
const cleanRow = {};
Object.keys(row).forEach(key => {
cleanRow[key.trim()] = row[key];
});
const name = cleanRow['Nama'] || cleanRow['name'] || cleanRow['Name'];
if (!name) {
logger.debug('[Import] Skipping row - Name is empty.');
continue;
}
const pkgName = cleanRow['Paket'] || cleanRow['package'] || cleanRow['Package'];
const pkg = packages.find(p => p.name === pkgName);
const odpName = cleanRow['ODP'] || cleanRow['odp'] || cleanRow['ODP Name'];
const odp = odps.find(o => o.name === odpName);
// ✅ Handle Router field
const routerName = cleanRow['Router'] || cleanRow['router'] || cleanRow['Router Name'];
const router = routers.find(r => r.name === routerName);
// ✅ NEW: Handle Connection Type
const connType = String(cleanRow['Tipe Koneksi'] || cleanRow['connection_type'] || cleanRow['Connection Type'] || 'pppoe').trim().toLowerCase() || 'pppoe';
const data = {
nik: cleanRow['NIK'] || cleanRow['nik'] || cleanRow['No KTP'] || cleanRow['No. KTP'] || '',
name: name,
phone: cleanRow['Telepon'] || cleanRow['phone'] || cleanRow['Phone'],
email: cleanRow['Email'] || cleanRow['email'] || cleanRow['email_address'],
address: cleanRow['Alamat'] || cleanRow['address'] || cleanRow['Address'],
area: cleanRow['Area'] || cleanRow['area'] || cleanRow['Wilayah'] || '',
package_id: pkg ? pkg.id : null,
router_id: router ? router.id : null,
odp_id: odp ? odp.id : null,
lat: cleanRow['Latitude'] || cleanRow['latitude'] || cleanRow['Lat'] || '',
lng: cleanRow['Longitude'] || cleanRow['longitude'] || cleanRow['Lng'] || '',
genieacs_tag: cleanRow['Tag ONU'] || cleanRow['genieacs_tag'],
pppoe_username: connType === 'pppoe' ? (cleanRow['PPPoE Username'] || cleanRow['pppoe_username'] || '') : '',
hotspot_username: connType === 'hotspot' ? (cleanRow['Hotspot Username'] || cleanRow['hotspot_username'] || '') : '',
static_ip: connType === 'static' ? (cleanRow['Static IP'] || cleanRow['static_ip'] || '') : '',
connection_type: connType,
isolir_profile: cleanRow['Isolir Profile'] || cleanRow['isolir_profile'] || 'isolir',
status: (cleanRow['Status'] || cleanRow['status'] || 'active').toLowerCase(),
install_date: cleanRow['Tanggal Pasang'] || cleanRow['install_date'],
auto_isolate: (cleanRow['Auto Isolir'] === 'TIDAK' || cleanRow['auto_isolate'] === 0) ? 0 : 1,
isolate_day: parseInt(cleanRow['Tgl Isolir'] || cleanRow['isolate_day']) || 10,
notes: cleanRow['Catatan'] || cleanRow['notes']
};
const id = cleanRow['ID'] || cleanRow['id'];
if (id && !isNaN(id) && id !== '') {
logger.info(`[Import] Updating customer ID: ${id}`);
customerSvc.updateCustomer(id, data);
} else {
logger.info(`[Import] Creating new customer: ${name}`);
customerSvc.createCustomer(data);
}
count++;
}
logger.info(`[Import] Finished. Total processed: ${count}`);
req.session._msg = { type: 'success', text: `Berhasil mengimpor ${count} data pelanggan.` };
} catch (e) {
logger.error('Import error:', e);
req.session._msg = { type: 'error', text: 'Gagal impor: ' + e.message };
}
res.redirect('/admin/customers');
});
router.post('/customers/:id/isolate', requireAdminSession, async (req, res) => {
try {
await customerSvc.suspendCustomer(req.params.id);
const customer = customerSvc.getCustomerById(req.params.id);
req.session._msg = { type: 'success', text: `Pelanggan "${customer.name}" berhasil di-isolir manual.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal isolir: ' + e.message };
}
res.redirect('back');
});
router.post('/customers/:id/unisolate', requireAdminSession, async (req, res) => {
try {
const customer = customerSvc.getCustomerById(req.params.id);
if (!customer) throw new Error('Pelanggan tidak ditemukan');
const unpaid = db.prepare("SELECT COUNT(*) as cnt FROM invoices WHERE customer_id=? AND status='unpaid'").get(req.params.id)?.cnt || 0;
const targetStatus = unpaid > 0 ? 'ditangguhkan' : 'active';
await customerSvc.activateCustomer(req.params.id, targetStatus);
const statusLabel = targetStatus === 'ditangguhkan' ? 'DITANGGUHKAN (Bebas auto-isolir sampai awal bulan berikutnya)' : 'AKTIF';
req.session._msg = { type: 'success', text: `Layanan pelanggan "${customer.name}" berhasil diaktifkan kembali (${statusLabel}).` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal aktivasi: ' + e.message };
}
res.redirect('back');
});
router.post('/customers/:id/billing/generate', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const { month, year } = req.body;
const result = billingSvc.generateInvoiceForCustomer(req.params.id, parseInt(month), parseInt(year));
if (result.created) {
req.session._msg = { type: 'success', text: `Tagihan berhasil dibuat untuk "${result.customerName}" periode ${month}/${year}.` };
} else {
req.session._msg = { type: 'success', text: `Tagihan sudah ada untuk "${result.customerName}" periode ${month}/${year}.` };
}
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal generate tagihan: ' + e.message };
}
res.redirect('back');
});
router.post('/customers/:id/billing/reset-promo-cycles', requireAdminSession, restrictToAdmin, (req, res) => {
try {
const r = customerSvc.resetPromoCyclesUsed(req.params.id);
if (!r.changes) {
req.session._msg = { type: 'error', text: 'Pelanggan tidak ditemukan.' };
} else {
const c = customerSvc.getCustomerById(req.params.id);
req.session._msg = { type: 'success', text: `Counter promo untuk "${c ? c.name : req.params.id}" di-reset (siklus promo dihitung ulang dari awal).` };
}
} catch (e) {
req.session._msg = { type: 'error', text: e.message || String(e) };
}
res.redirect('back');
});
router.post('/customers/:id/billing/install-prorata', requireAdminSession, restrictToAdmin, (req, res) => {
try {
const out = billingSvc.createInstallProrataCatchUpInvoice(req.params.id);
req.session._msg = {
type: 'success',
text: `Tagihan susulan prorata untuk "${out.customerName}" periode ${String(out.periodMonth).padStart(2, '0')}/${out.periodYear} sebesar Rp ${Number(out.amount).toLocaleString('id-ID')} (${out.billableDays}/${out.daysInMonth} hari).`
};
} catch (e) {
req.session._msg = { type: 'error', text: e.message || String(e) };
}
res.redirect('back');
});
router.post('/customers/:id/billing/pay', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const { month, months, year, paid_by_name, notes } = req.body;
const y = parseInt(year);
const paidBy = resolvePaidByName(req, paid_by_name);
const customer = customerSvc.getCustomerById(req.params.id);
if (months != null) {
const sum = billingSvc.payInvoicesForCustomerMonths(req.params.id, y, months, paidBy, notes);
const done = sum.paidMonths.length;
const already = sum.alreadyPaidMonths.length;
const created = sum.createdMonths.length;
const total = Number(sum.totalAmount) || 0;
req.session._msg = { type: 'success', text: `Pembayaran berhasil untuk "${sum.customerName}" tahun ${sum.year}. Total: Rp ${total.toLocaleString('id-ID')} (${sum.totalMonths || 0} bulan). Dibayar: ${done} bulan, dibuat: ${created}, sudah lunas: ${already}.` };
if (customer && customer.phone && done > 0) {
const monthsText = (sum.paidMonths || []).join(', ');
const pkg = customer.package_id ? customerSvc.getPackageById(customer.package_id) : null;
await sendPaymentSuccessWA(
customer.phone,
customer.name,
`${monthsText} / ${sum.year}`,
Number(total || 0).toLocaleString('id-ID'),
paidBy,
{
customerId: customer.id,
invoiceId: '',
username: customer.pppoe_username || customer.id,
customerCode: customer.customer_code || '',
packageName: pkg?.name || customer.package_name || '-',
paidAt: new Date(), method: String((req.body && req.body.payment_method) || '').trim()
}
);
}
} else {
const m = parseInt(month);
const result = billingSvc.payInvoiceForCustomerPeriod(req.params.id, m, y, paidBy, notes);
if (result.alreadyPaid) {
req.session._msg = { type: 'success', text: `Tagihan periode ${m}/${y} untuk "${result.customerName}" sudah lunas.` };
} else {
const verb = result.created ? 'dibuat & dilunasi' : 'dilunasi';
req.session._msg = { type: 'success', text: `Tagihan periode ${m}/${y} untuk "${result.customerName}" berhasil ${verb}.` };
if (customer && customer.phone) {
const invs = billingSvc.getInvoicesByAny(String(req.params.id)) || [];
const inv = (Array.isArray(invs) ? invs : []).find(i => Number(i?.period_month) === Number(m) && Number(i?.period_year) === Number(y)) || null;
const amount = inv ? Number(inv.amount || 0) : 0;
const pkg = customer.package_id ? customerSvc.getPackageById(customer.package_id) : null;
await sendPaymentSuccessWA(
customer.phone,
customer.name,
`${m}/${y}`,
amount.toLocaleString('id-ID'),
paidBy,
{
customerId: customer.id,
invoiceId: inv ? inv.id : '',
username: customer.pppoe_username || customer.id,
customerCode: customer.customer_code || '',
packageName: pkg?.name || customer.package_name || '-',
paidAt: new Date(), method: String((req.body && req.body.payment_method) || '').trim()
}
);
}
}
}
const freshCustomer = customerSvc.getAllCustomers().find(c => String(c.id) === String(req.params.id));
if (freshCustomer && freshCustomer.status === 'suspended' && freshCustomer.unpaid_count === 0) {
await customerSvc.activateCustomer(req.params.id);
}
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal bayar: ' + e.message };
}
res.redirect('back');
});
// ─── PACKAGES ──────────────────────────────────────────────────────────────
router.get('/packages', requireAdminSession, requireSidebarMenuAccess('packages'), async (req, res) => {
const selectedRouterId = req.selectedRouterId || (req.query.router_id ? Number(req.query.router_id) : null);
const routers = mikrotikService.getAllRouters();
const packages = customerSvc.getAllPackages(selectedRouterId);
try { const _pl = require('../services/packageProfileService'); packages.forEach(function (p) { p.rate_limit = _pl.rateLimitStr(p) || ''; }); } catch (e) {}
// Info profil isolir (READ-ONLY, mode-aware)
const _pkgMode = require('../services/packageProfileService').currentProfileMode();
const isolirSource = (_pkgMode === 'radius-attributes') ? 'radius' : 'mikrotik';
const isolirProfileName = String(getSetting('mikrotik_isolir_profile', 'isolir') || 'isolir');
let isolirExists = false, isolirLiveRl = '', isolirLiveLocal = '', isolirLiveRemote = '';
if (isolirSource === 'mikrotik') {
try {
const _pr = await mikrotikService.getPppoeProfiles(selectedRouterId || null);
const _ip = (_pr || []).find((x) => String(x.name || '').trim().toLowerCase() === isolirProfileName.toLowerCase());
if (_ip) { isolirExists = true; isolirLiveRl = _ip.rateLimit || ''; isolirLiveLocal = _ip.localAddress || ''; isolirLiveRemote = _ip.remoteAddress || ''; }
} catch (e) {}
}
const radiusIsolir = {
action: String(getSetting('radius_isolir_action', 'pool') || 'pool'),
pool: String(getSetting('radius_isolir_pool', 'isolir') || 'isolir'),
rateLimit: String(getSetting('radius_isolir_rate_limit', '512k/512k') || '512k/512k'),
ipPoolEnabled: String(getSetting('radius_isolir_ip_pool_enabled', '1')) === '1',
ipPoolStart: String(getSetting('radius_isolir_ip_pool_start', '10.10.99.2') || ''),
ipPoolEnd: String(getSetting('radius_isolir_ip_pool_end', '10.10.99.254') || '')
};
res.render('admin/packages', {
title: 'Paket Internet', company: company(), activePage: 'packages',
packages, routers, selectedRouterId, msg: flashMsg(req),
profileMode: _pkgMode, isolirSource, isolirProfileName, isolirExists, isolirLiveRl, isolirLiveLocal, isolirLiveRemote, radiusIsolir
});
});
router.post('/packages', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const ins = customerSvc.createPackage(req.body);
let extra = '';
const pkg = ins && ins.lastInsertRowid ? customerSvc.getPackageById(Number(ins.lastInsertRowid)) : null;
if (pkg) {
const r = await require('../services/packageProfileService').ensurePppProfileForPackage(pkg).catch(function () { return null; });
if (r && r.message) extra = ' ' + r.message;
}
req.session._msg = { type: 'success', text: `Paket "${req.body.name}" berhasil ditambahkan.` + extra };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/packages');
});
router.post('/packages/:id/update', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const _before = customerSvc.getPackageById(req.params.id);
customerSvc.updatePackage(req.params.id, req.body);
let extra = '';
let _cleanup = '';
if (_before && String(_before.name || '') !== String(req.body.name || '').trim() && String(_before.name || '').trim()) {
let _oldPoolUsed = false;
if (_before.pool_name) {
const _oc = db.prepare("SELECT COUNT(*) c FROM packages WHERE id != ? AND TRIM(COALESCE(pool_name,'')) = ?").get(req.params.id, String(_before.pool_name).trim()).c;
_oldPoolUsed = _oc > 0;
}
const _cr = await require('../services/packageProfileService').removePppProfileForPackage(_before, { removePool: !_oldPoolUsed }).catch(function () { return null; });
if (_cr && _cr.message) _cleanup = ' [lama] ' + _cr.message;
}
const pkg = customerSvc.getPackageById(req.params.id);
if (pkg) {
const r = await require('../services/packageProfileService').ensurePppProfileForPackage(pkg).catch(function () { return null; });
if (r && r.message) extra = ' ' + r.message;
}
req.session._msg = { type: 'success', text: 'Paket berhasil diperbarui.' + extra + _cleanup };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/packages');
});
router.post('/packages/:id/delete', requireAdminSession, async (req, res) => {
try {
const _cnt = db.prepare('SELECT COUNT(*) c FROM customers WHERE package_id=?').get(req.params.id).c;
const pkg = customerSvc.getPackageById(req.params.id);
let poolUsed = false;
if (pkg && pkg.pool_name) {
const other = db.prepare("SELECT COUNT(*) c FROM packages WHERE id != ? AND TRIM(COALESCE(pool_name,'')) = ?").get(req.params.id, String(pkg.pool_name).trim()).c;
poolUsed = other > 0;
}
customerSvc.deletePackage(req.params.id);
let extra = '';
if (pkg) {
const r = await require('../services/packageProfileService').removePppProfileForPackage(pkg, { removePool: !poolUsed }).catch(function () { return null; });
if (r && r.message) extra = ' ' + r.message;
}
req.session._msg = { type: 'success', text: 'Paket berhasil dihapus.' + extra + (_cnt ? (' ' + _cnt + ' pelanggan kini tanpa paket — harap perbarui paketnya.') : '') };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/packages');
});
// ─── VOUCHER PACKAGES (ON-DEMAND REAL-TIME CONFIGURATION) ────────────────────
router.get('/vouchers/packages', requireAdminSession, requireSidebarMenuAccess('voucher_packages'), (req, res) => {
const selectedRouterId = req.selectedRouterId || (req.query.router_id ? Number(req.query.router_id) : null);
const routers = db.prepare('SELECT id, name FROM routers WHERE is_active = 1').all();
res.render('admin/vouchers_packages', {
title: 'Paket Voucher Hotspot', company: company(), activePage: 'voucher_packages',
routers, selectedRouterId, msg: flashMsg(req)
});
});
router.get('/api/vouchers/packages', requireAdminSession, (req, res) => {
try {
const selectedRouterId = req.selectedRouterId || (req.query.router_id ? Number(req.query.router_id) : null);
let rows;
if (selectedRouterId && selectedRouterId > 0) {
rows = db.prepare(`
SELECT vp.*, r.name AS router_name
FROM voucher_packages vp
LEFT JOIN routers r ON r.id = vp.router_id
WHERE vp.router_id = ?
ORDER BY vp.price ASC
`).all(selectedRouterId);
} else {
rows = db.prepare(`
SELECT vp.*, r.name AS router_name
FROM voucher_packages vp
LEFT JOIN routers r ON r.id = vp.router_id
ORDER BY vp.price ASC
`).all();
}
res.json({ ok: true, rows });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.post('/api/vouchers/packages', requireAdminSession, express.json(), (req, res) => {
try {
const { router_id, profile_name, price, validity, prefix, code_length, charset, is_active } = req.body;
// VALIDATION: router_id is REQUIRED for voucher packages
if (!router_id || Number(router_id) <= 0) return res.status(400).json({ ok: false, error: 'Router harus dipilih' });
// Verify router exists
const router = db.prepare('SELECT id FROM routers WHERE id = ? LIMIT 1').get(Number(router_id));
if (!router) return res.status(400).json({ ok: false, error: 'Router tidak ditemukan di database' });
if (!profile_name) return res.status(400).json({ ok: false, error: 'Nama profil wajib diisi' });
if (!price || Number(price) <= 0) return res.status(400).json({ ok: false, error: 'Harga harus lebih besar dari 0' });
if (!validity) return res.status(400).json({ ok: false, error: 'Durasi/masa aktif wajib diisi' });
const rId = router_id ? Number(router_id) : null;
const prc = Math.floor(Number(price));
const len = Math.max(4, Math.min(16, Number(code_length) || 6));
const act = is_active === false || is_active === 0 || is_active === '0' ? 0 : 1;
const stmt = db.prepare(`
INSERT INTO voucher_packages (router_id, profile_name, price, validity, prefix, code_length, charset, is_active, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, (NOW_LOCAL()))
ON CONFLICT(router_id, profile_name) DO UPDATE SET
price=excluded.price,
validity=excluded.validity,
prefix=excluded.prefix,
code_length=excluded.code_length,
charset=excluded.charset,
is_active=excluded.is_active,
updated_at=(NOW_LOCAL())
`);
stmt.run(rId, profile_name, prc, String(validity).trim(), String(prefix || '').trim(), len, charset || 'mixed', act);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.post('/api/vouchers/packages/:id/delete', requireAdminSession, (req, res) => {
try {
db.prepare('DELETE FROM voucher_packages WHERE id = ?').run(req.params.id);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// ─── BILLING ───────────────────────────────────────────────────────────────
function adminActorFromReq(req) {
return { type: 'admin', id: req.session.adminUser || null, name: req.session.adminUser || 'Admin', ip: req.ip, userAgent: req.headers['user-agent'] };
}
router.get('/discounts', requireAdminSession, requireSidebarMenuAccess('discounts'), (req, res) => {
const timeInfo = getCurrentTimeInfo();
const { status: fStatus = 'active', search = '' } = req.query;
const discSvc = require('../services/discountService');
let allCustomers = [];
try { allCustomers = customerSvc.getAllCustomers() || []; } catch (e) { allCustomers = []; }
const customers = allCustomers.filter(c => {
if (fStatus && fStatus !== 'all' && String(c.status) !== fStatus) return false;
if (String(c.status) === 'suspended' && fStatus === 'active') return false;
if (search) {
const s = String(search).toLowerCase();
const hay = `${c.name || ''} ${c.phone || ''} ${c.customer_code || ''} ${c.pppoe_username || ''}`.toLowerCase();
if (!hay.includes(s)) return false;
}
return true;
});
const areas = [...new Set(allCustomers.map(c => c.area).filter(Boolean))].sort();
res.render('admin/discounts', {
title: 'Diskon Tagihan', company: company(), activePage: 'discounts',
customers, logs: discSvc.getDiscountLogs(100), fStatus, search, areas, timeInfo, msg: flashMsg(req)
});
});
router.post('/discounts/apply', requireAdminSession, requireSidebarMenuAccess('discounts'), express.urlencoded({ extended: true }), (req, res) => {
try {
const discSvc = require('../services/discountService');
const timeInfo = getCurrentTimeInfo();
const applyMode = String(req.body.apply_mode || 'selected');
const pMonth = parseInt(req.body.period_month, 10) || timeInfo.month;
const pYear = parseInt(req.body.period_year, 10) || timeInfo.year;
let customerIds = [];
let all = false;
if (applyMode === 'all') all = true;
else if (applyMode === 'single') { const cid = parseInt(req.body.customer_id, 10); if (cid) customerIds = [cid]; }
else { const arr = Array.isArray(req.body.customer_ids) ? req.body.customer_ids : (req.body.customer_ids ? [req.body.customer_ids] : []); customerIds = arr.map(x => parseInt(x, 10)).filter(Boolean); }
const result = discSvc.applyDiscountBatch({ customerIds, all, periodMonth: pMonth, periodYear: pYear, type: req.body.discount_type, value: req.body.discount_value, note: req.body.note, actor: adminActorFromReq(req) });
req.session._msg = { type: 'success', text: `Diskon diterapkan ke ${result.applied.length} tagihan (total potongan Rp ${Number(result.totalDiscount || 0).toLocaleString('id-ID')}). ${result.skipped.length} pelanggan dilewati (tidak ada tagihan belum bayar pada periode tsb).` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal menerapkan diskon: ' + e.message };
}
res.redirect('/admin/discounts');
});
router.post('/billing/:id/discount', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const discSvc = require('../services/discountService');
const r = discSvc.applyDiscountToInvoice(req.params.id, { type: req.body.discount_type, value: req.body.discount_value, note: req.body.note }, adminActorFromReq(req));
req.session._msg = { type: 'success', text: `Diskon diterapkan: Rp ${Number(r.amountBefore).toLocaleString('id-ID')} -> Rp ${Number(r.amountAfter).toLocaleString('id-ID')} (potongan Rp ${Number(r.discountAmount).toLocaleString('id-ID')}).` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal diskon: ' + e.message };
}
res.redirect(req.get('Referrer') || '/admin/billing');
});
router.get('/billing', requireAdminSession, requireSidebarMenuAccess('billing'), (req, res) => {
const timeInfo = getCurrentTimeInfo();
const { month: filterMonth, year: filterYear = timeInfo.year, status: filterStatus = 'all', search = '',
package_id: filterPackage = '', area: filterArea = '', router_id: filterRouter = '', collector_id: filterCollector = '' } = req.query;
const summary = billingSvc.getInvoiceSummary(filterMonth || timeInfo.month, filterYear);
const invoices = billingSvc.getAllInvoices({ month: filterMonth, year: filterYear, status: filterStatus, search,
packageId: filterPackage, area: filterArea, routerId: filterRouter, collectorId: filterCollector });
res.render('admin/billing', {
title: 'Tagihan', company: company(), activePage: 'billing',
invoices, summary, filterMonth, filterYear: parseInt(filterYear), filterStatus, search,
filterPackage, filterArea, filterRouter, filterCollector,
packages: customerSvc.getAllPackages(), areas: customerSvc.getAllCustomerAreas(),
routers: mikrotikService.getAllRouters(), collectors: adminSvc.getAllCollectors(),
msg: flashMsg(req)
});
});
router.get('/billing/:id/print', requireAdminSession, (req, res) => {
const inv = billingSvc.getInvoiceById(req.params.id);
if (!inv) return res.status(404).send('Invoice tidak ditemukan');
const customer = customerSvc.getCustomerById(inv.customer_id);
if (!customer) return res.status(404).send('Data pelanggan tidak ditemukan');
const settings = getSettings();
res.render('admin/print_invoice', {
invoice: inv,
customer,
company: settings.company_header || 'OPEN-ISP',
settings
});
});
router.get('/billing/:id/print-thermal', requireAdminSession, (req, res) => {
const inv = billingSvc.getInvoiceById(req.params.id);
if (!inv) return res.status(404).send('Invoice tidak ditemukan');
const customer = customerSvc.getCustomerById(inv.customer_id);
if (!customer) return res.status(404).send('Data pelanggan tidak ditemukan');
const settings = getSettings();
res.render('collector/print_thermal', {
invoice: inv,
customer,
company: settings.company_header || 'OPEN-ISP',
settings,
collectorName: req.session.adminUsername || 'Admin',
formatDateLocal,
formatTimeLocal,
getNowLocal
});
});
router.get('/billing/:id/pdf', requireAdminSession, async (req, res) => {
try {
const inv = billingSvc.getInvoiceById(req.params.id);
if (!inv) return res.status(404).send('Invoice tidak ditemukan');
const customer = customerSvc.getCustomerById(inv.customer_id);
if (!customer) return res.status(404).send('Data pelanggan tidak ditemukan');
const settings = getSettings();
const pdfBuffer = await pdfSvc.generateInvoicePdfBuffer(inv, customer, settings);
const safeName = (customer.name || 'Pelanggan').replace(/[^a-zA-Z0-9]/g, '_');
const filename = `Invoice_INV-${String(inv.id).padStart(4, '0')}_${safeName}.pdf`;
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${filename}"`,
'Content-Length': pdfBuffer.length
});
return res.send(pdfBuffer);
} catch (err) {
logger.error(`[PDF Download] Error: ${err.message}`);
return res.status(500).send('Gagal generate PDF invoice: ' + err.message);
}
});
router.post('/billing/:id/send-pdf-wa', requireAdminSession, async (req, res) => {
try {
const inv = billingSvc.getInvoiceById(req.params.id);
if (!inv) return res.json({ success: false, message: 'Invoice tidak ditemukan' });
const customer = customerSvc.getCustomerById(inv.customer_id);
if (!customer) return res.json({ success: false, message: 'Data pelanggan tidak ditemukan' });
if (!customer.phone) return res.json({ success: false, message: 'Nomor WhatsApp pelanggan belum terisi' });
const settings = getSettings();
const pdfBuffer = await pdfSvc.generateInvoicePdfBuffer(inv, customer, settings);
const mns = ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des'];
const periodStr = `${mns[(inv.period_month || 1) - 1]} ${inv.period_year}`;
const safeName = (customer.name || 'Pelanggan').replace(/[^a-zA-Z0-9]/g, '_');
const filename = `Invoice_${inv.id}_${safeName}.pdf`;
const statusText = inv.status === 'paid' ? 'LUNAS' : 'BELUM BAYAR';
const caption = `📄 *INVOICE PEMBAYARAN INTERNET*\n\nYth. *${customer.name}*,\nBerikut kami lampirkan dokumen resmi Invoice Pembayaran Internet untuk periode *${periodStr}*.\n\n💰 *Total Tagihan:* Rp ${Number(inv.amount || 0).toLocaleString('id-ID')}\n📌 *Status:* *${statusText}*\n\nTerima kasih telah menggunakan layanan *${settings.company_header || 'OPEN-ISP'}*!`;
const waNotify = require('../services/waNotifyService');
if (false) {
return res.json({ success: false, message: 'Bot WhatsApp belum terhubung' });
}
const sent = await waNotify.sendDocument(customer.phone, pdfBuffer, filename, caption);
if (sent) {
return res.json({ success: true, message: `Invoice PDF berhasil dikirim ke WhatsApp ${customer.name} (${customer.phone})` });
} else {
return res.json({ success: false, message: 'Gagal mengirim dokumen PDF ke WhatsApp' });
}
} catch (err) {
logger.error(`[Send PDF WA] Error: ${err.message}`);
return res.json({ success: false, message: 'Gagal kirim PDF: ' + err.message });
}
});
router.post('/billing/generate', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const { month, year } = req.body;
const count = billingSvc.generateMonthlyInvoices(parseInt(month), parseInt(year));
req.session._msg = { type: 'success', text: `${count} tagihan baru berhasil digenerate untuk periode ${month}/${year}.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal generate: ' + e.message };
}
res.redirect('/admin/billing');
});
router.get('/api/billing/unpaid/:customerId', requireAdmin, (req, res) => {
try {
const invoices = billingSvc.getUnpaidInvoicesByCustomerId(req.params.customerId);
res.json(invoices);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/api/customers/:id/paid-months', requireAdmin, (req, res) => {
try {
const year = parseInt(req.query.year || getCurrentTimeInfo().year);
const months = billingSvc.getPaidMonthsForCustomerYear(req.params.id, year);
res.json({ year, months });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/api/customers/:id/billing-year', requireAdmin, (req, res) => {
try {
const year = parseInt(req.query.year || getCurrentTimeInfo().year);
const summary = billingSvc.getCustomerBillingYearSummary(req.params.id, year);
res.json(summary);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/billing/pay-bulk', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const { invoice_ids, paid_by_name, notes } = req.body;
const ids = Array.isArray(invoice_ids) ? invoice_ids : [invoice_ids];
const paidBy = resolvePaidByName(req, paid_by_name);
if (!ids || ids.length === 0) throw new Error('Tidak ada tagihan yang dipilih');
const paidByCustomer = new Map();
const touchedCustomerIds = new Set();
let processed = 0;
for (const id of ids) {
const inv = billingSvc.getInvoiceById(id);
if (inv) {
processed++;
const customerId = Number(inv.customer_id || 0);
if (Number.isFinite(customerId) && customerId > 0) touchedCustomerIds.add(customerId);
const wasPaid = String(inv.status || '').toLowerCase() === 'paid';
billingSvc.markAsPaid(id, paidBy, notes);
if (!wasPaid) {
if (!paidByCustomer.has(customerId)) paidByCustomer.set(customerId, []);
paidByCustomer.get(customerId).push({
id: inv.id,
amount: Number(inv.amount || 0),
period_month: inv.period_month,
period_year: inv.period_year
});
}
}
}
const customersSnapshot = customerSvc.getAllCustomers();
for (const customerId of touchedCustomerIds) {
const freshCustomer = customersSnapshot.find(c => Number(c.id) === Number(customerId));
if (freshCustomer && freshCustomer.status === 'suspended' && Number(freshCustomer.unpaid_count || 0) === 0) {
await customerSvc.activateCustomer(customerId);
}
}
for (const [customerId, paidInvoices] of paidByCustomer.entries()) {
if (!paidInvoices || paidInvoices.length === 0) continue;
const customer = customerSvc.getCustomerById(customerId);
if (customer && customer.phone) {
const total = paidInvoices.reduce((a, b) => a + Number(b.amount || 0), 0);
const periods = paidInvoices
.map(x => whatsappService.formatIndonesianPeriod(x.period_month, x.period_year))
.slice(0, 10)
.join(', ') + (paidInvoices.length > 10 ? `, +${paidInvoices.length - 10} lainnya` : '');
const pkg = customer.package_id ? customerSvc.getPackageById(customer.package_id) : null;
await sendPaymentSuccessWA(
customer.phone,
customer.name,
periods,
Number(total || 0).toLocaleString('id-ID'),
paidBy,
{
customerId: customer.id,
invoiceId: paidInvoices.map(x => x.id).join(', '),
username: customer.pppoe_username || customer.id,
customerCode: customer.customer_code || '',
packageName: pkg?.name || customer.package_name || '-',
paidAt: new Date(), method: String((req.body && req.body.payment_method) || '').trim()
}
);
}
}
req.session._msg = { type: 'success', text: `${processed} tagihan berhasil diproses.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal bayar massal: ' + e.message };
}
res.redirect('back');
});
router.post('/billing/delete-bulk', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const { invoice_ids } = req.body;
const ids = Array.isArray(invoice_ids) ? invoice_ids : [invoice_ids];
const clean = ids
.map(x => Number(x))
.filter(n => Number.isFinite(n) && n > 0);
if (!clean || clean.length === 0) throw new Error('Tidak ada tagihan yang dipilih');
let deleted = 0;
for (const id of clean) {
try {
billingSvc.deleteInvoice(id);
deleted++;
} catch {}
}
req.session._msg = { type: 'success', text: `${deleted} tagihan berhasil dihapus.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal hapus massal: ' + e.message };
}
res.redirect('back');
});
router.post('/billing/:id/pay', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const inv = billingSvc.getInvoiceById(req.params.id);
if (!inv) throw new Error('Tagihan tidak ditemukan');
const paidBy = resolvePaidByName(req, req.body.paid_by_name);
const wasPaid = String(inv.status || '').toLowerCase() === 'paid';
if (!wasPaid) {
billingSvc.markAsPaid(req.params.id, paidBy, req.body.notes);
}
// Check if customer is currently suspended and has no more unpaid invoices
const customer = customerSvc.getCustomerById(inv.customer_id);
if (!wasPaid && customer && customer.phone) {
const pkg = customer.package_id ? customerSvc.getPackageById(customer.package_id) : null;
await sendPaymentSuccessWA(
customer.phone,
customer.name,
`${inv.period_month}/${inv.period_year}`,
Number(inv.amount || 0).toLocaleString('id-ID'),
paidBy,
{
customerId: customer.id,
invoiceId: inv.id,
username: customer.pppoe_username || customer.id,
customerCode: customer.customer_code || '',
packageName: pkg?.name || customer.package_name || '-',
paidAt: new Date(), method: String((req.body && req.body.payment_method) || '').trim()
}
);
}
if (customer && customer.status === 'suspended') {
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) {
await customerSvc.activateCustomer(inv.customer_id);
}
}
req.session._msg = { type: 'success', text: wasPaid ? 'Tagihan ini memang sudah lunas sebelumnya.' : 'Tagihan berhasil ditandai lunas.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('back');
});
router.post('/billing/:id/unpay', requireAdminSession, (req, res) => {
try {
billingSvc.markAsUnpaid(req.params.id);
req.session._msg = { type: 'success', text: 'Status tagihan direset ke Belum Bayar.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('back');
});
router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
try {
const invId = Number(req.params.id);
if (!Number.isFinite(invId) || invId <= 0) throw new Error('Invoice ID tidak valid');
const force = String(req.query.force || '') === '1';
const inv = db.prepare('SELECT id, status, amount, qris_amount_unique FROM invoices WHERE id=?').get(invId);
if (!inv) throw new Error('Tagihan tidak ditemukan');
if (String(inv.status) !== 'unpaid') throw new Error('Hanya tagihan BELUM BAYAR yang bisa dibuat kode QRIS.');
if (!force && inv.qris_amount_unique) {
req.session._msg = { type: 'success', text: 'Kode QRIS sudah ada untuk tagihan ini.' };
return res.redirect('back');
}
const baseAmount = Number(inv.amount || 0);
if (!Number.isFinite(baseAmount) || baseAmount <= 0) throw new Error('Nominal tagihan tidak valid');
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
const update = db.prepare(`
UPDATE invoices
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
WHERE id=?
`);
let chosenCode = 0;
let chosenAmount = 0;
for (let i = 0; i < 50; i++) {
const code = 1 + Math.floor(Math.random() * 999);
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
chosenCode = code;
chosenAmount = amount;
break;
}
}
if (!chosenAmount) {
for (let code = 1; code <= 999; code++) {
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
chosenCode = code;
chosenAmount = amount;
break;
}
}
}
if (!chosenAmount) throw new Error('Gagal membuat nominal unik (slot 1-999 penuh).');
update.run(chosenCode, chosenAmount, invId);
req.session._msg = { type: 'success', text: `Kode QRIS dibuat: Rp ${Number(chosenAmount).toLocaleString('id-ID')} (kode ${chosenCode}).` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal membuat kode QRIS: ' + e.message };
}
res.redirect('back');
});
router.post('/billing/:id/qris-clear', requireAdminSession, (req, res) => {
try {
const invId = Number(req.params.id);
if (!Number.isFinite(invId) || invId <= 0) throw new Error('Invoice ID tidak valid');
db.prepare(`
UPDATE invoices
SET qris_unique_code=NULL, qris_amount_unique=NULL, qris_assigned_at=NULL
WHERE id=?
`).run(invId);
req.session._msg = { type: 'success', text: 'Kode QRIS dihapus dari tagihan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal menghapus kode QRIS: ' + e.message };
}
res.redirect('back');
});
router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
try {
const waEnabled = getSetting('whatsapp_enabled', false);
const billingEnabled = getSetting('whatsapp_billing_to_customer_enabled', true);
if (!waEnabled) throw new Error('Notifikasi WhatsApp sedang dinonaktifkan di Pengaturan.');
if (!billingEnabled) throw new Error('Notifikasi tagihan WhatsApp ke pelanggan sedang dinonaktifkan.');
const inv = billingSvc.getInvoiceById(req.params.id);
if (!inv) throw new Error('Tagihan tidak ditemukan');
const customer = customerSvc.getCustomerById(inv.customer_id);
if (!customer || !customer.phone) throw new Error('Nomor WhatsApp pelanggan tidak ditemukan');
const waNotify = require('../services/waNotifyService');
if (false) {
throw new Error('Bot WhatsApp belum terhubung. Silakan cek status WhatsApp di menu Admin.');
}
const unpaidInvoices = billingSvc.getUnpaidInvoicesByCustomerId(customer.id);
const summary = whatsappService.formatUnpaidInvoicesSummary(
(unpaidInvoices && unpaidInvoices.length > 0) ? unpaidInvoices : [inv]
);
const totalTagihan = summary.totalAmount > 0 ? summary.totalAmount : Number(inv.amount || 0);
const rincianBulan = summary.periodText;
const rincianDetail = summary.isMultiple ? `📋 *Rincian Tunggakan:*\n${summary.breakdownText}\n` : '';
let qrisAmountUnique = Number(inv.qris_amount_unique || 0) || 0;
let qrisCode = Number(inv.qris_unique_code || 0) || 0;
const qrisQrUrl = String(getSetting('qris_static_qr_url', '') || '').trim();
const qrisPayloadSetting = String(getSetting('qris_static_payload', '') || '');
const qrisEnabledRaw = getSetting('qris_static_enabled', true);
const qrisEnabled = !(qrisEnabledRaw === false || qrisEnabledRaw === 'false' || qrisEnabledRaw === 0 || qrisEnabledRaw === '0');
const hasStaticQris = qrisEnabled && (!!qrisQrUrl || !!String(qrisPayloadSetting || '').trim());
const baseAmount = totalTagihan > 0 ? totalTagihan : Number(inv.amount || 0);
if (hasStaticQris && String(inv.status) === 'unpaid' && (!qrisAmountUnique || !qrisCode || (qrisAmountUnique - qrisCode !== baseAmount))) {
const invId = Number(inv.id);
if (Number.isFinite(invId) && invId > 0 && Number.isFinite(baseAmount) && baseAmount > 0) {
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
const update = db.prepare(`
UPDATE invoices
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
WHERE id=?
`);
let chosenCode = qrisCode > 0 ? qrisCode : 0;
let chosenAmount = 0;
if (chosenCode > 0) {
const pAmt = baseAmount + chosenCode;
if (!exists.get('unpaid', pAmt, invId)) {
chosenAmount = pAmt;
}
}
if (!chosenAmount) {
for (let i = 0; i < 50; i++) {
const code = 1 + Math.floor(Math.random() * 999);
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
chosenCode = code;
chosenAmount = amount;
break;
}
}
}
if (!chosenAmount) {
for (let code = 1; code <= 999; code++) {
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
chosenCode = code;
chosenAmount = amount;
break;
}
}
}
if (chosenAmount) {
update.run(chosenCode, chosenAmount, invId);
qrisAmountUnique = chosenAmount;
qrisCode = chosenCode;
}
}
}
const normalizeQrisPayload = (raw) => {
let s = String(raw || '').replace(/[\r\n\t]+/g, '').trim();
const idx = s.indexOf('000201');
if (idx > 0) s = s.slice(idx);
const lastCrc = s.lastIndexOf('6304');
if (lastCrc >= 0 && s.length >= lastCrc + 8) {
s = s.slice(0, lastCrc + 8);
}
return s;
};
const crc16CcittFalse = (input) => {
const s = String(input || '');
let crc = 0xffff;
for (let i = 0; i < s.length; i++) {
crc ^= (s.charCodeAt(i) & 0xff) << 8;
for (let b = 0; b < 8; b++) {
if (crc & 0x8000) crc = ((crc << 1) ^ 0x1021) & 0xffff;
else crc = (crc << 1) & 0xffff;
}
}
return crc & 0xffff;
};
const parseEmvTlvString = (input) => {
const raw = String(input || '').replace(/[\r\n\t]+/g, '').trim();
if (!raw) throw new Error('QRIS payload kosong');
if (raw.length < 8) throw new Error('QRIS payload terlalu pendek');
const items = [];
let i = 0;
while (i < raw.length) {
if (i + 4 > raw.length) throw new Error('QRIS payload TLV tidak valid');
const tag = raw.slice(i, i + 2);
const lenStr = raw.slice(i + 2, i + 4);
if (!/^\d{2}$/.test(lenStr)) throw new Error('QRIS payload TLV length tidak valid');
const len = Number(lenStr);
const start = i + 4;
const end = start + len;
if (end > raw.length) throw new Error('QRIS payload TLV length melebihi data');
const value = raw.slice(start, end);
items.push({ tag, value });
i = end;
}
return items;
};
const buildEmvTlvString = (items) => {
const list = Array.isArray(items) ? items : [];
let out = '';
for (const it of list) {
const tag = String(it?.tag || '');
const value = String(it?.value ?? '');
const len = value.length;
if (!/^\d{2}$/.test(tag)) throw new Error('Tag TLV tidak valid');
if (len > 99) throw new Error('TLV length > 99 tidak didukung');
out += tag + String(len).padStart(2, '0') + value;
}
return out;
};
const convertStaticQrisToDynamic = (staticPayload, amount) => {
const amt = Math.max(0, Math.floor(Number(amount || 0) || 0));
if (!amt) throw new Error('Nominal QRIS dinamis tidak valid');
const source = parseEmvTlvString(staticPayload)
.filter(x => x && x.tag)
.map(x => ({ tag: String(x.tag), value: String(x.value ?? '') }));
const managed = new Set(['54', '55', '56', '57', '63']);
const result = [];
let amountInserted = false;
for (const el of source) {
if (managed.has(el.tag)) continue;
if (el.tag === '01') {
result.push({ tag: '01', value: '12' });
continue;
}
if (el.tag === '58' && !amountInserted) {
result.push({ tag: '54', value: String(amt) });
amountInserted = true;
}
result.push(el);
}
if (!amountInserted) result.push({ tag: '54', value: String(amt) });
const body = buildEmvTlvString(result);
const partial = body + '6304';
const crc = crc16CcittFalse(partial).toString(16).toUpperCase().padStart(4, '0');
return partial + crc;
};
let _decodedCache = global.__adminQrisDecodedCache || { file: '', mtimeMs: 0, payload: '' };
const decodeQrisPayloadFromUploadedQr = async () => {
const m = String(qrisQrUrl || '').match(/^\/uploads\/qris\/([^/?#]+)$/i);
if (!m || !m[1]) return '';
const safeName = path.basename(String(m[1]));
const filePath = path.join(__dirname, '../public/uploads/qris', safeName);
let st = null;
try {
st = await fs.promises.stat(filePath);
} catch {
return '';
}
if (_decodedCache.file === safeName && _decodedCache.mtimeMs === st.mtimeMs && _decodedCache.payload) {
return _decodedCache.payload;
}
try {
const buf = await fs.promises.readFile(filePath);
const img = await Jimp.read(buf);
const rgba = new Uint8ClampedArray(img.bitmap.data.buffer, img.bitmap.data.byteOffset, img.bitmap.data.byteLength);
const source = new RGBLuminanceSource(rgba, img.bitmap.width, img.bitmap.height);
const bitmap = new BinaryBitmap(new HybridBinarizer(source));
const reader = new MultiFormatReader();
const hints = new Map();
hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.QR_CODE]);
reader.setHints(hints);
const decoded = reader.decode(bitmap);
const text = typeof decoded?.getText === 'function' ? decoded.getText() : String(decoded?.text || '');
const payload = normalizeQrisPayload(text);
if (!payload) return '';
_decodedCache = { file: safeName, mtimeMs: st.mtimeMs, payload };
global.__adminQrisDecodedCache = _decodedCache;
return payload;
} catch {
return '';
}
};
const resolveQrisStaticPayload = async () => {
const fromSetting = normalizeQrisPayload(qrisPayloadSetting);
if (fromSetting) return fromSetting;
return await decodeQrisPayloadFromUploadedQr();
};
// Generate Link Login
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.get('host');
let baseUrl = String(getSetting('app_url', '') || `${protocol}://${host}`).replace(/\/+$/, '');
try {
const parsed = new URL(baseUrl);
baseUrl = parsed.origin;
} catch {}
const loginLink = `${baseUrl}/customer/login`;
const comp = company();
const defaultAutoBilling = "{Halo|Salam} Bapak/Ibu *{{nama}}*, 🙏\n\nBerikut kami sampaikan rincian tagihan layanan internet Anda:\n\n📋 *RINGKASAN TAGIHAN*\n• ID Pelanggan : *{{id_pelanggan}}*\n• Paket : {{paket}}\n• Total Tagihan : *Rp {{tagihan}}*\n• Periode : {{rincian}}\n\n{Mohon|Silakan} melakukan pembayaran melalui portal pelanggan:\n{{link}}\n\nTerima kasih atas perhatian dan kerja samanya. Semoga harinya menyenangkan. 🙏\n— Admin {{company}}";
const defaultQris = `{Halo|Selamat Pagi|Yth.} Pelanggan {{nama}},\n\n{Berikut|Ini adalah} rincian tagihan manual + Kode Bayar QRIS Anda:\n\n📦 *Paket:* {{paket}}\n📅 *Periode:* {{periode}}\n💰 *Nominal:* Rp {{qris_nominal}}\n\n{Silakan scan|Mohon scan} QRIS terlampir untuk melakukan pembayaran otomatis:\n{{qris_qr}}\n\nTerima kasih.`;
const templateQris = db.getAppSetting('whatsapp_billing_qris_message', defaultQris);
const template = db.getAppSetting('whatsapp_auto_billing_message', defaultAutoBilling);
const isQrisCase = (qrisAmountUnique > 0 && qrisCode > 0);
const finalNominal = qrisAmountUnique > 0 ? qrisAmountUnique : totalTagihan;
const finalNominalStr = Number(finalNominal).toLocaleString('id-ID');
const displayPeriode = summary.isMultiple
? `${rincianBulan}\n\n${rincianDetail}`.trim()
: rincianBulan;
const qrisJpgLink = `${baseUrl}/customer/qris/static.jpg?amount=${encodeURIComponent(String(finalNominal))}`;
const qrisJpgCaption = isQrisCase
? templateQris
.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(/{{company}}/gi, company())
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
.replace(/{{link}}/gi, loginLink)
.replace(/{{rincian_detail}}/gi, rincianDetail)
.replace(/{{periode}}/gi, displayPeriode)
.replace(/{{rincian}}/gi, displayPeriode)
.replace(/{{paket}}/gi, inv.package_name || customer.package_name || '-')
.replace(/{{qris_nominal}}/gi, finalNominalStr)
.replace(/{{tagihan}}/gi, finalNominalStr)
.replace(/{{qris_kode}}/gi, String(qrisCode).padStart(3, '0'))
.replace(/{{qris_qr}}/gi, `QRIS terlampir (gambar).\n🌐 Portal Pelanggan: ${loginLink}`)
: '';
const formattedMsg = isQrisCase
? templateQris
.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(/{{company}}/gi, company())
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
.replace(/{{link}}/gi, loginLink)
.replace(/{{rincian_detail}}/gi, rincianDetail)
.replace(/{{periode}}/gi, displayPeriode)
.replace(/{{rincian}}/gi, displayPeriode)
.replace(/{{paket}}/gi, inv.package_name || customer.package_name || '-')
.replace(/{{qris_nominal}}/gi, finalNominalStr)
.replace(/{{tagihan}}/gi, finalNominalStr)
.replace(/{{qris_kode}}/gi, String(qrisCode).padStart(3, '0'))
.replace(/{{qris_qr}}/gi, `🔗 Link QRIS JPG: ${qrisJpgLink}\n🌐 Portal Pelanggan: ${loginLink}`)
: 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(/{{company}}/gi, company())
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
.replace(/{{link}}/gi, loginLink)
.replace(/{{tagihan}}/gi, finalNominalStr)
.replace(/{{qris_nominal}}/gi, finalNominalStr)
.replace(/{{rincian_detail}}/gi, rincianDetail)
.replace(/{{periode}}/gi, displayPeriode)
.replace(/{{rincian}}/gi, displayPeriode)
.replace(/{{paket}}/gi, inv.package_name || customer.package_name || '-')
.replace(/{{link}}/gi, loginLink);
let sent = false;
if (isQrisCase) {
try {
const payloadNorm = await resolveQrisStaticPayload();
if (payloadNorm) {
const jpg = await qrisUtil.buildDynamicQrisJpgBuffer(payloadNorm, qrisAmountUnique);
sent = await waNotify.sendImage(customer.phone, jpg, qrisJpgCaption);
}
} catch (e) {
sent = false;
}
}
if (!sent) {
sent = await waNotify.sendText(customer.phone, formattedMsg);
}
if (!sent) throw new Error('Gagal mengirim pesan melalui WhatsApp Bot.');
req.session._msg = { type: 'success', text: `Tagihan WhatsApp berhasil dikirim ke ${customer.name}.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal kirim WA: ' + e.message };
}
res.redirect('back');
});
router.post('/billing/:id/delete', requireAdminSession, (req, res) => {
try {
billingSvc.deleteInvoice(req.params.id);
req.session._msg = { type: 'success', text: 'Tagihan berhasil dihapus.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('back');
});
// ─── TICKETS ───────────────────────────────────────────────────────────────
const ticketSvc = require('../services/ticketService');
router.get('/tickets', requireAdminSession, requireSidebarMenuAccess('tickets'), (req, res) => {
const { status = 'all', search = '', tech_id: filterTech = '' } = req.query;
const tickets = ticketSvc.getAllTickets(status, { search, technicianId: filterTech });
const stats = ticketSvc.getTicketStats();
const customers = customerSvc.getAllCustomers();
const techSvc = require('../services/techService');
const technicians = techSvc.getAllTechnicians().filter(t => t.is_active === 1);
res.render('admin/tickets', {
title: 'Keluhan & Tugas Teknisi', company: company(), activePage: 'tickets',
tickets, stats, customers, technicians, filterStatus: status, filterSearch: search, filterTech: filterTech, msg: flashMsg(req)
});
});
router.post('/tickets/create', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const { customer_id, subject, message, technician_id, status } = req.body;
if (!subject || !message) {
req.session._msg = { type: 'error', text: 'Subjek dan Detail Pesan wajib diisi.' };
return res.redirect('/admin/tickets');
}
const custId = customer_id ? parseInt(customer_id, 10) : 0;
const techId = technician_id ? parseInt(technician_id, 10) : null;
const ticketStatus = status || (techId ? 'in_progress' : 'open');
const result = ticketSvc.createTicket(custId, subject, message, {
technicianId: techId,
status: ticketStatus
});
const ticketId = result.lastInsertRowid;
req.session._msg = { type: 'success', text: 'Tiket/tugas baru berhasil dibuat!' };
// --- WHATSAPP NOTIFICATION FOR NEW ADMIN CREATED TICKET ---
try {
const settings = getSettings();
if (settings.whatsapp_enabled) {
const waNotify = require('../services/waNotifyService');
const cust = custId ? customerSvc.getCustomerById(custId) : null;
const techSvc = require('../services/techService');
const tech = techId ? techSvc.getTechById(techId) : null;
const custName = cust ? cust.name : 'Tugas Umum / Maintenance Admin';
const custPhone = cust ? cust.phone : '-';
const custAddr = cust ? cust.address : '-';
const _waDigits = String(custPhone || '').replace(/\D/g, '').replace(/^0/, '62');
const _mapQ = (cust && cust.lat && cust.lng) ? (cust.lat + ',' + cust.lng) : String(custAddr || '');
const _base = (getSetting('public_base_url', '') || getSetting('app_url', '') || ('http://' + (req.get('host') || ''))).replace(/\/+$/, '');
const _links = `\n🔗 *Tautan Cepat:*\n` +
(_waDigits ? `• 💬 Chat Pelanggan: https://wa.me/${_waDigits}\n` : '') +
(_mapQ ? `• 📍 Share Lokasi: https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(_mapQ)}` : '');
const waMsg = `📌 *TUGAS TEKNISI BARU DARI ADMIN*\n\n` +
`🎫 *ID Tiket:* #${ticketId}\n` +
`👤 *Pelanggan/Objek:* ${custName}\n` +
`📞 *Kontak:* ${custPhone}\n` +
`📍 *Alamat:* ${custAddr}\n` +
`📝 *Kendala/Tugas:* ${subject}\n` +
`💬 *Detail Pesan:* ${message}\n\n` +
`Silakan cek di portal teknisi/admin untuk menindaklanjuti.` + _links;
// Send to assigned technician or broadcast to all active technicians if not assigned
if (tech && tech.phone) {
let digits = String(tech.phone).replace(/\D/g, '');
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
await waNotify.sendText(digits, waMsg);
} else if (!techId) {
const technicians = techSvc.getAllTechnicians().filter(t => t.is_active === 1 && t.phone);
for (const t of technicians) {
let digits = String(t.phone).replace(/\D/g, '');
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
await waNotify.sendText(digits, waMsg);
}
}
}
} catch (waErr) {
console.error(`[AdminPortal] WA Ticket Create Notification Error: ${waErr.message}`);
}
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal membuat tiket: ' + e.message };
}
res.redirect('/admin/tickets');
});
router.post('/tickets/:id/update', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const { status, technician_id } = req.body;
const ticketId = req.params.id;
const oldTicket = ticketSvc.getTicketById(ticketId);
const techId = (technician_id && parseInt(technician_id, 10) > 0) ? parseInt(technician_id, 10) : null;
ticketSvc.updateTicketStatus(ticketId, status, techId);
req.session._msg = { type: 'success', text: 'Status & penugasan keluhan berhasil diperbarui.' };
// --- WHATSAPP NOTIFICATION IF TECHNICIAN IS ASSIGNED / CHANGED ---
if (techId && (!oldTicket || Number(oldTicket.technician_id) !== techId)) {
try {
const settings = getSettings();
if (settings.whatsapp_enabled) {
const waNotify = require('../services/waNotifyService');
const techSvc = require('../services/techService');
const newTech = techSvc.getTechById(techId);
const updatedTicket = ticketSvc.getTicketById(ticketId);
if (newTech && newTech.phone && updatedTicket) {
let digits = String(newTech.phone).replace(/\D/g, '');
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
const waMsg = `📌 *PENUGASAN TIKET OLEH ADMIN*\n\n` +
`🎫 *ID Tiket:* #${updatedTicket.id}\n` +
`👤 *Pelanggan/Objek:* ${updatedTicket.customer_name}\n` +
`📞 *Kontak:* ${updatedTicket.customer_phone || '-'}\n` +
`📍 *Alamat:* ${updatedTicket.customer_address || '-'}\n` +
`📝 *Kendala/Tugas:* ${updatedTicket.subject}\n` +
`💬 *Detail Pesan:* ${updatedTicket.message}\n` +
`📊 *Status:* ${status}\n\n` +
`Silakan cek portal teknisi untuk memproses tugas ini.`;
await waNotify.sendText(digits, waMsg);
}
}
} catch (waErr) {
console.error(`[AdminPortal] WA Assign Notification Error: ${waErr.message}`);
}
}
// --- WHATSAPP NOTIFICATION FOR RESOLVED TICKET (BY ADMIN) ---
if (status === 'resolved') {
try {
const settings = getSettings();
if (settings.whatsapp_enabled) {
const waNotify = require('../services/waNotifyService');
const ticket = ticketSvc.getTicketById(ticketId);
if (ticket) {
const waMsg = `✅ *TIKET KELUHAN SELESAI*\n\n` +
`🎫 *ID Tiket:* #${ticket.id}\n` +
`👤 *Pelanggan:* ${ticket.customer_name}\n` +
`📝 *Subjek:* ${ticket.subject}\n` +
`🛠️ *Petugas:* Admin\n\n` +
`Keluhan Anda telah selesai dikerjakan. Terima kasih atas kesabarannya.`;
// Kirim ke Pelanggan
if (ticket.customer_phone) {
await waNotify.sendText(ticket.customer_phone, waMsg);
}
// Kirim ke Admin Numbers
if (settings.whatsapp_admin_numbers && settings.whatsapp_admin_numbers.length > 0) {
const adminMsg = `✅ *LAPORAN TIKET SELESAI (OLEH ADMIN)*\n\n` +
`🎫 *ID Tiket:* #${ticket.id}\n` +
`👤 *Pelanggan:* ${ticket.customer_name}\n` +
`📝 *Subjek:* ${ticket.subject}\n` +
`💬 *Pesan:* ${ticket.message}`;
const seen = new Set();
for (const adminPhone of settings.whatsapp_admin_numbers) {
let digits = String(adminPhone || '').replace(/\D/g, '');
if (!digits) continue;
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
if (seen.has(digits)) continue;
seen.add(digits);
await waNotify.sendText(digits, adminMsg);
}
}
}
}
} catch (waErr) {
console.error(`[AdminPortal] WA Notification Error: ${waErr.message}`);
}
}
// -------------------------------------------------------------
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal update keluhan: ' + e.message };
}
res.redirect('back');
});
router.post('/tickets/:id/delete', requireAdminSession, (req, res) => {
try {
ticketSvc.deleteTicket(req.params.id);
req.session._msg = { type: 'success', text: 'Keluhan berhasil dihapus.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal hapus keluhan: ' + e.message };
}
res.redirect('back');
});
// ─── REPORTS ───────────────────────────────────────────────────────────────
router.get('/reports', requireAdminSession, requireSidebarMenuAccess('reports'), (req, res) => {
const filterYear = parseInt(req.query.year) || new Date().getFullYear();
const now = new Date();
const monthlyData = billingSvc.getMonthlyRevenue(filterYear);
const recentPayments = billingSvc.getRecentPayments(10);
const topUnpaid = billingSvc.getTopUnpaid(5);
const activeCustomers = customerSvc.getCustomerStats().active;
const yStr = String(filterYear);
const revenueYearAllRow = db.prepare(
"SELECT SUM(amount) as t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at) = ?"
).get(yStr);
const revenueYearAll = Number(revenueYearAllRow?.t || 0);
const revenueYearDirectRow = db.prepare(
"SELECT SUM(amount) as t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at) = ? AND (paid_by_name IS NULL OR paid_by_name NOT LIKE 'Agent %')"
).get(yStr);
const revenueYearDirect = Number(revenueYearDirectRow?.t || 0);
const revenueYearAgent = Math.max(0, revenueYearAll - revenueYearDirect);
const agentDepositYearRow = ({ t: 0 });
const agentDepositYear = Number(agentDepositYearRow?.t || 0);
const nowYearStr = String(now.getFullYear());
const nowMonthStr = String(now.getMonth() + 1).padStart(2, '0');
const revenueThisMonthAllRow = db.prepare(
"SELECT SUM(amount) as t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at) = ? AND strftime('%m', paid_at) = ?"
).get(nowYearStr, nowMonthStr);
const revenueThisMonthAll = Number(revenueThisMonthAllRow?.t || 0);
const revenueThisMonthDirectRow = db.prepare(
"SELECT SUM(amount) as t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at) = ? AND strftime('%m', paid_at) = ? AND (paid_by_name IS NULL OR paid_by_name NOT LIKE 'Agent %')"
).get(nowYearStr, nowMonthStr);
const revenueThisMonthDirect = Number(revenueThisMonthDirectRow?.t || 0);
const revenueThisMonthAgent = Math.max(0, revenueThisMonthAll - revenueThisMonthDirect);
const agentDepositMonthRow = ({ t: 0 });
const agentDepositThisMonth = Number(agentDepositMonthRow?.t || 0);
const customCashInYearRow = db.prepare("SELECT SUM(amount) as t FROM cash_in WHERE strftime('%Y', date) = ?").get(yStr);
const customCashInYear = Number(customCashInYearRow?.t || 0);
const customCashInMonthRow = db.prepare("SELECT SUM(amount) as t FROM cash_in WHERE strftime('%Y', date) = ? AND strftime('%m', date) = ?").get(nowYearStr, nowMonthStr);
const customCashInMonth = Number(customCashInMonthRow?.t || 0);
const cashInYear = revenueYearDirect + agentDepositYear + customCashInYear;
const cashInThisMonth = revenueThisMonthDirect + agentDepositThisMonth + customCashInMonth;
const pendingAmountRow = db.prepare("SELECT SUM(amount) as t FROM invoices WHERE status='unpaid'").get();
const pendingAmount = Number(pendingAmountRow?.t || 0);
const voidedYearRow = db.prepare("SELECT COUNT(*) as c, SUM(amount) as t FROM invoices WHERE status='void' AND period_year = ?").get(yStr);
const voidedYearCount = Number(voidedYearRow?.c || 0);
const voidedYearAmount = Number(voidedYearRow?.t || 0);
const expensesYearRow = db.prepare("SELECT SUM(amount) as t FROM expenses WHERE strftime('%Y', date) = ?").get(yStr);
const expensesRegularYear = Number(expensesYearRow?.t || 0);
const expensesYear = expensesRegularYear;
const expensesMonthRow = db.prepare("SELECT SUM(amount) as t FROM expenses WHERE strftime('%Y', date) = ? AND strftime('%m', date) = ?").get(nowYearStr, nowMonthStr);
const expensesRegularMonth = Number(expensesMonthRow?.t || 0);
const expensesMonth = expensesRegularMonth;
const voidedMonthRow = db.prepare("SELECT SUM(amount) as t FROM invoices WHERE status='void' AND strftime('%Y', voided_at) = ? AND strftime('%m', voided_at) = ?").get(nowYearStr, nowMonthStr);
const voidedMonthAmount = Number(voidedMonthRow?.t || 0);
const netProfitYear = cashInYear - expensesYear - voidedYearAmount;
const netProfitMonth = cashInThisMonth - expensesMonth - voidedMonthAmount;
const expensesByCategory = db.prepare("SELECT category, SUM(amount) as total FROM expenses WHERE strftime('%Y', date) = ? GROUP BY category").all(yStr);
expensesByCategory.sort((a, b) => b.total - a.total);
// ── Pajak & Retribusi (lazy finance) ──
const fin = {
ppn: Number(getSetting('finance_ppn_pct', '11')) || 0,
pph: Number(getSetting('finance_pph_pct', '22')) || 0,
uso: Number(getSetting('finance_uso_pct', '1.75')) || 0,
bhp: Number(getSetting('finance_bhp_pct', '0.5')) || 0,
inclPpn: String(getSetting('finance_revenue_includes_ppn', '0')) === '1'
};
const lab = (bruto, keluar) => {
const dpp = (fin.inclPpn && fin.ppn > 0) ? (bruto / (1 + fin.ppn / 100)) : bruto;
const ppn = fin.inclPpn ? (bruto - dpp) : (bruto * fin.ppn / 100);
const uso = dpp * fin.uso / 100;
const bhp = dpp * fin.bhp / 100;
const sebelumPph = dpp - keluar - uso - bhp;
const pph = Math.max(0, sebelumPph) * fin.pph / 100;
const bersih = sebelumPph - pph;
return { bruto, dpp, ppn: Math.max(0, ppn), uso, bhp, keluar, sebelumPph, pph, bersih };
};
const ply = lab(cashInYear, expensesYear);
const pm = lab(cashInThisMonth, expensesMonth);
const _revM = db.prepare("SELECT CAST(strftime('%m', paid_at) AS INTEGER) m, COALESCE(SUM(amount),0) t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at)=? AND (paid_by_name IS NULL OR paid_by_name NOT LIKE 'Agent %') GROUP BY m").all(yStr);
const _expM = db.prepare("SELECT CAST(strftime('%m', date) AS INTEGER) m, COALESCE(SUM(amount),0) t FROM expenses WHERE strftime('%Y', date)=? GROUP BY m").all(yStr);
const _revMap = {}; _revM.forEach(r => { _revMap[r.m] = Number(r.t); });
const _expMap = {}; _expM.forEach(r => { _expMap[r.m] = Number(r.t); });
const monthlyFinance = [];
for (let _m = 1; _m <= 12; _m++) { monthlyFinance.push(Object.assign({ month: _m }, lab(_revMap[_m] || 0, _expMap[_m] || 0))); }
res.render('admin/reports', {
title: 'Laporan Laba / Rugi', company: company(), activePage: 'reports',
filterYear, monthlyData, chartData: monthlyData, recentPayments, topUnpaid,
totalRevenue: revenueYearAll,
thisMonth: revenueThisMonthAll,
pendingAmount,
voidedYearCount,
voidedYearAmount,
voidedMonthAmount,
activeCustomers,
revenueYearAgent,
revenueThisMonthAgent,
agentDepositYear,
agentDepositThisMonth,
customCashInYear,
customCashInMonth,
cashInYear,
cashInThisMonth,
expensesYear,
expensesMonth,
netProfitYear,
netProfitMonth,
expensesByCategory,
finance: fin, ply, pm, monthlyFinance
});
});
router.post('/reports/finance', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
const keep = (v, d) => { const n = parseFloat(v); return Number.isFinite(n) && n >= 0 ? String(n) : String(d); };
const cur = getSettings();
const next = {
...cur,
finance_ppn_pct: keep(req.body.finance_ppn_pct, 11),
finance_pph_pct: keep(req.body.finance_pph_pct, 22),
finance_uso_pct: keep(req.body.finance_uso_pct, 1.75),
finance_bhp_pct: keep(req.body.finance_bhp_pct, 0.5),
finance_revenue_includes_ppn: String(req.body.finance_revenue_includes_ppn) === '1' ? '1' : '0'
};
saveSettings(next);
req.session._msg = { type: 'success', text: 'Pengaturan pajak & retribusi disimpan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal menyimpan: ' + e.message };
}
res.redirect('/admin/reports');
});
router.get('/reports/print', requireAdminSession, requireSidebarMenuAccess('reports'), (req, res) => {
const filterYear = parseInt(req.query.year) || new Date().getFullYear();
const filterMonth = parseInt(req.query.month) || 0;
const yStr = String(filterYear);
const monthlyData = billingSvc.getMonthlyRevenue(filterYear);
const settings = getSettingsWithCache();
const activeCustomers = customerSvc.getCustomerStats().active;
// Kalkulasi sama seperti laporan utama
const revenueYearDirect = Number(db.prepare("SELECT SUM(amount) as t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at) = ? AND (paid_by_name IS NULL OR paid_by_name NOT LIKE 'Agent %')").get(yStr)?.t || 0);
const agentDepositYear = Number(({ t: 0 })?.t || 0);
const customCashInYear = Number(db.prepare("SELECT SUM(amount) as t FROM cash_in WHERE strftime('%Y', date) = ?").get(yStr)?.t || 0);
const cashInYear = revenueYearDirect + agentDepositYear + customCashInYear;
const expensesRegularYear = Number(db.prepare("SELECT SUM(amount) as t FROM expenses WHERE strftime('%Y', date) = ?").get(yStr)?.t || 0);
const expensesYear = expensesRegularYear;
const netProfitYear = cashInYear - expensesYear;
const pendingAmountRow = db.prepare("SELECT SUM(amount) as t FROM invoices WHERE status='unpaid' AND period_year = ?").get(filterYear);
const pendingAmount = Number(pendingAmountRow?.t || 0);
const expensesByCategory = db.prepare("SELECT category, SUM(amount) as total FROM expenses WHERE strftime('%Y', date) = ? GROUP BY category").all(yStr);
expensesByCategory.sort((a, b) => b.total - a.total);
// voided (dibutuhkan view print; sebelumnya belum dihitung di route ini)
const _now = new Date();
const _nowYearStr = String(_now.getFullYear());
const _nowMonthStr = String(_now.getMonth() + 1).padStart(2, '0');
const voidedYearRow = db.prepare("SELECT COUNT(*) as c, SUM(amount) as t FROM invoices WHERE status='void' AND period_year = ?").get(filterYear);
const voidedYearCount = Number(voidedYearRow?.c || 0);
const voidedYearAmount = Number(voidedYearRow?.t || 0);
const voidedMonthRow = db.prepare("SELECT SUM(amount) as t FROM invoices WHERE status='void' AND strftime('%Y', voided_at) = ? AND strftime('%m', voided_at) = ?").get(_nowYearStr, _nowMonthStr);
const voidedMonthAmount = Number(voidedMonthRow?.t || 0);
const _mPad = String(filterMonth).padStart(2, '0');
const _brutoP = filterMonth
? (Number(db.prepare("SELECT SUM(amount) as t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at)=? AND strftime('%m', paid_at)=?").get(yStr, _mPad)?.t || 0)
+ Number(db.prepare("SELECT SUM(amount) as t FROM cash_in WHERE strftime('%Y', date)=? AND strftime('%m', date)=?").get(yStr, _mPad)?.t || 0))
: cashInYear;
const _keluarP = filterMonth
? Number(db.prepare("SELECT SUM(amount) as t FROM expenses WHERE strftime('%Y', date)=? AND strftime('%m', date)=?").get(yStr, _mPad)?.t || 0)
: expensesYear;
const finP = finCfg();
const LR = labaRugi(_brutoP, _keluarP, finP);
res.render('admin/reports_print', {
company: company(),
settings,
filterYear,
filterMonth,
fin: finP,
LR,
cashInYear,
expensesYear,
netProfitYear,
pendingAmount,
voidedYearCount,
voidedYearAmount,
voidedMonthAmount,
activeCustomers,
monthlyData,
expensesByCategory,
formatDateLocal,
getNowLocal
});
});
router.get('/reports/pdf', requireAdminSession, requireSidebarMenuAccess('reports'), async (req, res) => {
try {
const filterYear = parseInt(req.query.year) || new Date().getFullYear();
const filterMonth = parseInt(req.query.month) || 0;
const mode = String(req.query.mode || 'after') === 'before' ? 'before' : 'after';
const yStr = String(filterYear);
const _mPad = String(filterMonth).padStart(2, '0');
const fin = finCfg();
const q = (s, ...a) => Number((db.prepare(s).get(...a) || {}).t || 0);
const brutoP = filterMonth
? (q("SELECT COALESCE(SUM(amount),0) t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at)=? AND strftime('%m', paid_at)=? AND (paid_by_name IS NULL OR paid_by_name NOT LIKE 'Agent %')", yStr, _mPad)
+ q("SELECT COALESCE(SUM(amount),0) t FROM cash_in WHERE strftime('%Y', date)=? AND strftime('%m', date)=?", yStr, _mPad))
: (q("SELECT COALESCE(SUM(amount),0) t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at)=? AND (paid_by_name IS NULL OR paid_by_name NOT LIKE 'Agent %')", yStr)
+ q("SELECT COALESCE(SUM(amount),0) t FROM cash_in WHERE strftime('%Y', date)=?", yStr));
const keluarP = filterMonth
? q("SELECT COALESCE(SUM(amount),0) t FROM expenses WHERE strftime('%Y', date)=? AND strftime('%m', date)=?", yStr, _mPad)
: q("SELECT COALESCE(SUM(amount),0) t FROM expenses WHERE strftime('%Y', date)=?", yStr);
const ply = labaRugi(brutoP, keluarP, fin);
const _revM = db.prepare("SELECT CAST(strftime('%m', paid_at) AS INTEGER) m, COALESCE(SUM(amount),0) t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at)=? AND (paid_by_name IS NULL OR paid_by_name NOT LIKE 'Agent %') GROUP BY m").all(yStr);
const _expM = db.prepare("SELECT CAST(strftime('%m', date) AS INTEGER) m, COALESCE(SUM(amount),0) t FROM expenses WHERE strftime('%Y', date)=? GROUP BY m").all(yStr);
const _rM = {}; _revM.forEach(r => { _rM[r.m] = Number(r.t); });
const _eM = {}; _expM.forEach(r => { _eM[r.m] = Number(r.t); });
const monthlyFinance = [];
for (let m = 1; m <= 12; m++) monthlyFinance.push(Object.assign({ month: m }, labaRugi(_rM[m] || 0, _eM[m] || 0, fin)));
const pdf = await require('../services/financeReportPdfService').generateFinanceReportPdfBuffer({
company: company(), settings: getSettings(), filterYear, filterMonth, finance: fin, ply, pm: ply, monthlyFinance, mode
});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="Laporan_Keuangan_' + filterYear + (filterMonth ? ('-' + _mPad) : '') + '_' + mode + '.pdf"');
res.send(pdf);
} catch (e) {
res.status(500).send('Gagal membuat PDF: ' + e.message);
}
});
router.get('/reports/export-csv', requireAdminSession, requireSidebarMenuAccess('reports'), (req, res) => {
const filterYear = parseInt(req.query.year) || new Date().getFullYear();
const filterMonth = parseInt(req.query.month) || 0;
const yStr = String(filterYear);
const fin = finCfg();
const q = (s, ...a) => Number((db.prepare(s).get(...a) || {}).t || 0);
const num = (v) => Math.round(Number(v) || 0);
let csvContent = '';
let fname = '';
if (filterMonth) {
const mStr = String(filterMonth).padStart(2, '0');
const bruto = q("SELECT COALESCE(SUM(amount),0) t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at)=? AND strftime('%m', paid_at)=? AND (paid_by_name IS NULL OR paid_by_name NOT LIKE 'Agent %')", yStr, mStr)
+ q("SELECT COALESCE(SUM(amount),0) t FROM cash_in WHERE strftime('%Y', date)=? AND strftime('%m', date)=?", yStr, mStr);
const keluar = q("SELECT COALESCE(SUM(amount),0) t FROM expenses WHERE strftime('%Y', date)=? AND strftime('%m', date)=?", yStr, mStr);
const L = labaRugi(bruto, keluar, fin);
csvContent += `Laporan Laba/Rugi ${company()} - Bulan ${mStr}/${filterYear}\n\n`;
csvContent += "Keterangan,Nilai (Rp)\n";
csvContent += `Pemasukan (Bruto),${num(L.bruto)}\nDPP,${num(L.dpp)}\nPPN Keluaran (${fin.ppn}%),${num(L.ppn)}\nPengeluaran,${num(L.keluar)}\nUSO (${fin.uso}%),${num(L.uso)}\nBHP (${fin.bhp}%),${num(L.bhp)}\nLaba Sebelum PPh,${num(L.sebelumPph)}\nPPh Badan (${fin.pph}%),${num(L.pph)}\nLaba Bersih,${num(L.bersih)}\n\n`;
const invs = db.prepare("SELECT i.paid_at, c.name, i.amount FROM invoices i LEFT JOIN customers c ON c.id=i.customer_id WHERE i.status='paid' AND strftime('%Y', i.paid_at)=? AND strftime('%m', i.paid_at)=? ORDER BY i.paid_at").all(yStr, mStr);
csvContent += "=== Pemasukan (Invoice Lunas) ===\nTanggal,Pelanggan,Nominal\n";
invs.forEach(r => { csvContent += `${r.paid_at},"${(r.name || '').replace(/"/g, '""')}",${r.amount}\n`; });
const exps = db.prepare("SELECT date, category, amount, description FROM expenses WHERE strftime('%Y', date)=? AND strftime('%m', date)=? ORDER BY date").all(yStr, mStr);
csvContent += "\n=== Pengeluaran ===\nTanggal,Kategori,Nominal,Deskripsi\n";
exps.forEach(e => { csvContent += `${e.date},"${(e.category || '').replace(/"/g, '""')}",${e.amount},"${(e.description || '').replace(/"/g, '""')}"\n`; });
fname = `Laporan_Keuangan_${filterYear}-${mStr}.csv`;
} else {
const expenses = db.prepare("SELECT date, category, amount, description FROM expenses WHERE strftime('%Y', date) = ? ORDER BY date ASC").all(yStr);
const cashIn = db.prepare("SELECT date, category, amount, description FROM cash_in WHERE strftime('%Y', date) = ? ORDER BY date ASC").all(yStr);
const bruto = q("SELECT COALESCE(SUM(amount),0) t FROM invoices WHERE status='paid' AND strftime('%Y', paid_at)=?", yStr)
+ q("SELECT COALESCE(SUM(amount),0) t FROM cash_in WHERE strftime('%Y', date)=?", yStr);
const keluar = q("SELECT COALESCE(SUM(amount),0) t FROM expenses WHERE strftime('%Y', date)=?", yStr);
const L = labaRugi(bruto, keluar, fin);
csvContent += `Laporan Keuangan ${company()} - Tahun ${filterYear}\n\n`;
csvContent += "=== RINGKASAN LABA/RUGI ===\nKeterangan,Nilai (Rp)\n";
csvContent += `Pemasukan (Bruto),${num(L.bruto)}\nDPP,${num(L.dpp)}\nPPN Keluaran (${fin.ppn}%),${num(L.ppn)}\nPengeluaran,${num(L.keluar)}\nUSO (${fin.uso}%),${num(L.uso)}\nBHP (${fin.bhp}%),${num(L.bhp)}\nLaba Sebelum PPh,${num(L.sebelumPph)}\nPPh Badan (${fin.pph}%),${num(L.pph)}\nLaba Bersih,${num(L.bersih)}\n\n`;
csvContent += "=== DATA PENGELUARAN ===\nTanggal,Kategori,Nominal,Deskripsi\n";
expenses.forEach(e => { csvContent += `${e.date},"${e.category}",${e.amount},"${(e.description || '').replace(/"/g, '""')}"\n`; });
csvContent += "\n=== DATA KAS MASUK TAMBAHAN ===\nTanggal,Sumber/Kategori,Nominal,Deskripsi\n";
cashIn.forEach(c => { csvContent += `${c.date},"${c.category}",${c.amount},"${(c.description || '').replace(/"/g, '""')}"\n`; });
fname = `Laporan_Keuangan_${filterYear}.csv`;
}
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${fname}"`);
res.send(csvContent);
});
// ─── SETTINGS ──────────────────────────────────────────────────────────────
router.get('/sidebar-settings', requireAdminSession, (req, res) => {
res.render('admin/sidebar_settings', {
title: 'Pengaturan Sidebar',
company: company(),
activePage: 'sidebar_settings',
msg: flashMsg(req),
canManageSidebar: Boolean(req.session?.isAdmin),
menuConfigs: sidebarMenuSvc.getConfigMenus(),
featureContactPhone: sidebarMenuSvc.getFeatureContactPhone()
});
});
router.post('/sidebar-settings', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
const featurePassword = String(req.body.feature_password || '').trim();
if (!sidebarMenuSvc.isFeaturePasswordValid(featurePassword)) {
throw new Error(`Password aktivasi salah. Hubungi ${sidebarMenuSvc.getFeatureContactPhone()} untuk mendapatkan password yang benar.`);
}
const menuStates = sidebarMenuSvc.sanitizeMenuStates(req.body.menu_state || {});
const success = sidebarMenuSvc.saveMenuStates(menuStates);
if (!success) throw new Error('Gagal menyimpan pengaturan sidebar');
if (auditSvc && typeof auditSvc.logAuditTrail === 'function') {
auditSvc.logAuditTrail({
action: 'UPDATE',
entity_type: 'sidebar_settings',
entity_id: 'global',
actor_type: req.session?.isAdmin ? 'admin' : 'cashier',
actor_id: String(req.session?.adminUser || req.session?.cashierUsername || ''),
actor_name: req.session?.adminUser || req.session?.cashierName || 'Admin',
details: { menuStates, password_verified: true },
ip_address: req.ip,
user_agent: req.get('user-agent')
});
}
req.session._msg = { type: 'success', text: 'Pengaturan sidebar berhasil disimpan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal menyimpan pengaturan sidebar: ' + e.message };
}
res.redirect('/admin/sidebar-settings');
});
router.get('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), (req, res) => {
res.render('admin/service_notice', {
title: 'Status Layanan & Pengumuman', company: company(), activePage: 'service_notice',
settings: getSettings(), msg: flashMsg(req)
});
});
router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), express.urlencoded({ extended: true }), (req, res) => {
try {
const allowed = ['NORMAL', 'GANGGUAN_MASAL', 'PERBAIKAN'];
let mode = String(req.body.notice_mode || 'NORMAL').trim().toUpperCase();
if (allowed.indexOf(mode) === -1) mode = 'NORMAL';
let interval = parseInt(req.body.notice_interval_hours, 10);
if (!interval || interval < 1) interval = 5;
if (interval > 168) interval = 168;
saveSettings({
notice_mode: mode,
notice_start: String(req.body.notice_start || '').trim(),
notice_interval_hours: interval,
notice_note: String(req.body.notice_note || '').trim()
});
const noticeSvc = require('../services/serviceNoticeService');
const n = noticeSvc.computeNotice();
const detail = n.active ? (n.label + (n.estimateText ? ' — estimasi ' + n.estimateText : '')) : 'Normal (tanpa pengumuman)';
req.session._msg = { type: 'success', text: 'Status layanan diperbarui: ' + detail };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal menyimpan status layanan: ' + e.message };
}
res.redirect('/admin/service-notice');
});
router.post('/customers/auto-deactivate-now', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const months = parseInt(getSetting('auto_deactivate_months', 3), 10) || 3;
const r = await customerSvc.autoDeactivateOverdueCustomers(months);
return res.json({ success: true, processed: r.processed, candidates: r.candidates, message: `Auto-nonaktif: ${r.processed} pelanggan dinonaktifkan dari ${r.candidates} kandidat (>= ${months} bulan).` });
} catch (e) {
return res.status(500).json({ success: false, message: e.message });
}
});
router.post('/registrations/:id/approve', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const id = Number(req.params.id);
const cust = customerSvc.getCustomerById(id);
if (!cust) throw new Error('Pendaftar tidak ditemukan');
let total = parseInt(req.body.final_total, 10);
if (!Number.isFinite(total) || total < 0) total = Number(cust.reg_total || 0);
// Periode tagihan pertama = bulan jatuh tempo (acuan tgl 5)
const due = String(cust.reg_due_date || '');
let pMonth, pYear;
if (/^\d{4}-\d{2}-\d{2}$/.test(due)) { pYear = Number(due.slice(0, 4)); pMonth = Number(due.slice(5, 7)); }
else { const ti = getCurrentTimeInfo(); pMonth = ti.month || (new Date().getMonth() + 1); pYear = ti.year || new Date().getFullYear(); }
// Auto-generate akun PPPoE bila belum ada (bisa dimatikan di Pengaturan RADIUS/PPPoE)
const _autoGenPppoe = getSetting('pppoe_auto_generate', '1') === '1';
let pppoeUser = cust.pppoe_username || '';
let pppoePass = cust.pppoe_password || '';
if (!pppoeUser && _autoGenPppoe) { try { const g = customerSvc.generatePppoeAccount(id); pppoeUser = g.username; pppoePass = g.password; } catch (e) {} }
// Provisioning: RADIUS baca DB langsung; mode MikroTik perlu push secret ke router
try {
const _radiusEnabled = getSetting('radius_enabled', '0') === '1';
const _isRadius = _radiusEnabled ? (Number(cust.is_radius) === 1 ? 1 : 0) : 0;
db.prepare('UPDATE customers SET is_radius=? WHERE id=?').run(_isRadius, id);
const _shouldSyncToMikrotik = !_radiusEnabled || !_isRadius;
if (_shouldSyncToMikrotik && pppoeUser) {
const _pkg = cust.package_id ? customerSvc.getPackageById(cust.package_id) : null;
await mikrotikService.createPppoeSecret({
username: pppoeUser,
password: pppoePass,
profile: _pkg ? _pkg.name : '',
remoteAddress: cust.pppoe_remote_address || '',
routerId: cust.router_id || null
});
logger.info('[RegApprove] PPPoE secret dipush ke MikroTik: ' + pppoeUser + ' (profile ' + (_pkg ? _pkg.name : '-') + ')');
} else if (pppoeUser) {
logger.info('[RegApprove] Mode RADIUS - kredensial dibaca dari DB (tanpa push MikroTik): ' + pppoeUser);
}
} catch (e) { try { logger.error('[RegApprove] Provisioning PPPoE gagal: ' + e.message); } catch (_) {} }
const existingInv = db.prepare('SELECT id FROM invoices WHERE customer_id=? AND period_month=? AND period_year=?').get(id, pMonth, pYear);
if (!existingInv) {
db.prepare('INSERT INTO invoices (customer_id, period_month, period_year, amount, status, notes) VALUES (?,?,?,?,?,?)')
.run(id, pMonth, pYear, total, 'unpaid', 'Tagihan awal pendaftaran (biaya pasang + prorata)');
}
const installDate = cust.install_date || String(cust.created_at || '').slice(0, 10) || new Date().toISOString().slice(0, 10);
const inFee = parseInt(req.body.reg_install_fee, 10);
const inPro = parseInt(req.body.reg_prorata, 10);
const inDisc = parseInt(req.body.reg_discount, 10);
db.prepare('UPDATE customers SET status=?, reg_status=?, reg_total=?, reg_install_fee=COALESCE(?,reg_install_fee), reg_prorata=COALESCE(?,reg_prorata), reg_discount=COALESCE(?,reg_discount), install_date=? WHERE id=?')
.run('active', 'approved', total, Number.isFinite(inFee) ? inFee : null, Number.isFinite(inPro) ? inPro : null, Number.isFinite(inDisc) ? inDisc : null, installDate, id);
// Notifikasi WA ke pelanggan: tagihan pertama + QRIS dinamis
try {
const waNotify = require('../services/waNotifyService');
const invoiceQris = require('../services/invoiceQrisService');
const invRow = db.prepare('SELECT * FROM invoices WHERE customer_id=? AND period_month=? AND period_year=?').get(id, pMonth, pYear);
if (cust.phone && invRow) {
const q = invoiceQris.ensureInvoiceQris(invRow);
const mns = ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des'];
const periodStr = (mns[pMonth - 1] || pMonth) + ' ' + pYear;
const pkg = cust.package_id ? customerSvc.getPackageById(cust.package_id) : null;
const comp = getSetting('company_header', 'OPEN-ISP');
const caption = 'Halo *' + cust.name + '*,\n\n' +
'🎉 Selamat bergabung! Berikut tagihan internet pertama Anda:\n\n' +
'📋 *RINGKASAN TAGIHAN*\n' +
'• ID Pelanggan : *' + (cust.customer_code || '-') + '*\n' +
'• Paket : ' + (pkg ? pkg.name : (cust.package_name || '-')) + '\n' +
'• Total Tagihan : *Rp ' + Number(invRow.amount || 0).toLocaleString('id-ID') + '*\n' +
'• Periode : ' + periodStr + '\n\n' +
'Silakan scan QRIS di atas untuk melakukan pembayaran.\n\n' +
'Terima kasih.\n— ' + comp;
if (q.payload) {
const QRCode = require('qrcode');
const png = await QRCode.toBuffer(q.payload, { width: 512, margin: 1 });
await waNotify.sendImage(cust.phone, png, caption);
} else {
await waNotify.sendText(cust.phone, caption);
}
}
} catch (e) { try { logger.error('[RegApprove] WA error: ' + e.message); } catch (_) {} }
// Kirim kredensial PPPoE ke admin & teknisi
try {
if (pppoeUser) {
const waNotify = require('../services/waNotifyService');
const pkgC = cust.package_id ? customerSvc.getPackageById(cust.package_id) : null;
const credMsg = '🔑 *KREDENSIAL PPPoE PELANGGAN BARU*\n\n' +
'👤 *Nama:* ' + cust.name + '\n' +
'🆔 *ID:* ' + (cust.customer_code || '-') + '\n' +
'📦 *Paket:* ' + (pkgC ? pkgC.name : (cust.package_name || '-')) + '\n' +
'📡 *PPPoE User:* *' + pppoeUser + '*\n' +
'🔒 *PPPoE Pass:* *' + (pppoePass || '-') + '*\n' +
'📍 *Alamat:* ' + (cust.address || '-') + '\n\n' +
'Silakan konfigurasi perangkat pelanggan dengan data di atas.';
await waNotify.notifyAdmin(credMsg);
}
} catch (e) { try { logger.error('[RegApprove] staff cred error: ' + e.message); } catch (_) {} }
req.session._msg = { type: 'success', text: 'Pendaftar ' + cust.name + ' disetujui (aktif) + tagihan pertama dibuat (Rp ' + total.toLocaleString('id-ID') + ', periode ' + pMonth + '/' + pYear + ').' };
} catch (e) { req.session._msg = { type: 'error', text: 'Gagal menyetujui: ' + e.message }; }
res.redirect(req.get('Referrer') || '/admin/customers?status=inactive');
});
router.post('/registrations/:id/reject', requireAdminSession, (req, res) => {
try {
const id = Number(req.params.id);
const cust = customerSvc.getCustomerById(id);
if (!cust) throw new Error('Pendaftar tidak ditemukan');
db.prepare("UPDATE customers SET reg_status='rejected' WHERE id=?").run(id);
req.session._msg = { type: 'success', text: 'Pendaftar ' + cust.name + ' ditolak.' };
} catch (e) { req.session._msg = { type: 'error', text: 'Gagal menolak: ' + e.message }; }
res.redirect(req.get('Referrer') || '/admin/customers?status=inactive');
});
router.get('/vouchers-discounts', requireAdminSession, requireSidebarMenuAccess('vouchers_discounts'), (req, res) => {
const voucherSvc = require('../services/voucherService');
const editId = parseInt(req.query.edit, 10) || 0;
res.render('admin/vouchers_discounts', {
title: 'Voucher Diskon', company: company(), activePage: 'vouchers_discounts',
vouchers: voucherSvc.list(),
editVoucher: editId ? voucherSvc.getById(editId) : null,
installFee: parseInt(getSetting('registration_install_fee', 0), 10) || 0,
pppoeDomain: String(getSetting('pppoe_domain', 'sky.id') || 'sky.id'),
pppoeDefaultPassword: String(getSetting('pppoe_default_password', 'openisp') || 'openisp'),
msg: flashMsg(req)
});
});
router.post('/vouchers-discounts/save', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const voucherSvc = require('../services/voucherService');
if (req.body.id) voucherSvc.update(req.body.id, req.body);
else voucherSvc.create(req.body);
req.session._msg = { type: 'success', text: 'Voucher berhasil disimpan.' };
} catch (e) { req.session._msg = { type: 'error', text: 'Gagal menyimpan voucher: ' + e.message }; }
res.redirect('/admin/vouchers-discounts');
});
router.post('/vouchers-discounts/:id/toggle', requireAdminSession, (req, res) => {
try {
const voucherSvc = require('../services/voucherService');
const v = voucherSvc.getById(req.params.id);
if (v) voucherSvc.update(v.id, { active: v.active ? 0 : 1 });
req.session._msg = { type: 'success', text: 'Status voucher diubah.' };
} catch (e) { req.session._msg = { type: 'error', text: 'Gagal: ' + e.message }; }
res.redirect('/admin/vouchers-discounts');
});
router.post('/vouchers-discounts/:id/delete', requireAdminSession, (req, res) => {
try { require('../services/voucherService').remove(req.params.id); req.session._msg = { type: 'success', text: 'Voucher dihapus.' }; }
catch (e) { req.session._msg = { type: 'error', text: 'Gagal: ' + e.message }; }
res.redirect('/admin/vouchers-discounts');
});
router.post('/vouchers-discounts/settings', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const fee = parseInt(req.body.registration_install_fee, 10) || 0;
if (fee <= 0) throw new Error('Biaya pasang tidak boleh 0. Untuk menggratiskan biaya pasang, gunakan kode voucher.');
saveSettings({
registration_install_fee: fee,
pppoe_domain: String(req.body.pppoe_domain || 'sky.id').replace(/^@/, '').trim() || 'sky.id',
pppoe_default_password: String(req.body.pppoe_default_password || 'openisp').trim() || 'openisp'
});
req.session._msg = { type: 'success', text: 'Biaya pasang disimpan: Rp ' + fee.toLocaleString('id-ID') + '.' };
} catch (e) { req.session._msg = { type: 'error', text: 'Gagal: ' + e.message }; }
res.redirect('/admin/vouchers-discounts');
});
function waTemplateDefaults(comp) {
return {
whatsapp_auto_billing_message: `Yth. Pelanggan {{nama}},
Ini adalah pengingat sebelum tanggal jatuh tempo/isolir.
📦 *Paket:* {{paket}}
💰 *Total Tagihan:* Rp {{tagihan}}
📅 *Periode:* {{rincian}}
Mohon segera melakukan pembayaran melalui portal pelanggan: {{link}}
Terima kasih atas kerja samanya.
Salam,
Admin ${comp || ''}`,
whatsapp_billing_qris_message: `Yth. Pelanggan {{nama}},
Berikut rincian tagihan manual + Kode Bayar QRIS Anda:
📦 *Paket:* {{paket}}
📅 *Periode:* {{periode}}
💰 *Nominal:* Rp {{qris_nominal}}
Silakan scan QRIS berikut untuk melakukan pembayaran otomatis:
{{qris_qr}}
Terima kasih.`,
whatsapp_payment_success_message: whatsappService.DEFAULT_PAYMENT_SUCCESS_TEMPLATE,
whatsapp_isolir_message: `{Halo|Salam} Bapak/Ibu *{{nama}}*,
Dengan hormat, layanan internet Anda untuk sementara kami nonaktifkan karena tagihan belum kami terima.
ID Pelanggan: *{{id_pelanggan}}*
Paket: {{paket}}
Total Tagihan: *Rp {{tagihan}}*
Mohon berkenan menyelesaikan pembayaran agar layanan dapat kami aktifkan kembali:
{{link}}
Terima kasih atas perhatian dan kerja samanya. — {{company}}`,
whatsapp_arrears_warning_message: `Pemberitahuan Tagihan Internet
Yth. Bapak/Ibu *{{nama}}* (ID Pelanggan: {{id_pelanggan}}),
Dengan hormat, pembayaran tagihan layanan internet Anda belum kami terima selama *{{bulan_nunggak}} bulan*.
Paket: {{paket}}
Total Tagihan: Rp {{tagihan}}
Periode: {{rincian}}
Mohon berkenan menyelesaikan pembayaran sebelum {{batas}} bulan agar layanan tetap aktif:
{{link}}
Terima kasih atas perhatian dan kerja samanya. — {{company}}`,
whatsapp_autolunas_message: `✅ *Tagihan LUNAS*
Yth. Bapak/Ibu *{{nama}}* (ID Pelanggan: {{id_pelanggan}}),
Terima kasih, tagihan layanan internet Anda telah kami tandai *LUNAS* secara otomatis.
💵 Nominal: *Rp {{tagihan}}*
📅 Periode: {{rincian}}
Terima kasih 🙏
— {{company}}`,
whatsapp_deactivated_message: `⚠️ *Layanan Dinonaktifkan*
Yth. Bapak/Ibu *{{nama}}* (ID Pelanggan: {{id_pelanggan}}),
Layanan internet Anda untuk sementara kami *nonaktifkan* karena tagihan belum diselesaikan selama *{{bulan_nunggak}} bulan berturut-turut*.
Bayar: {{link}}
— {{company}}`,
whatsapp_deleted_message: `🗑️ *Akun Dihapus*
Yth. *{{nama}}*,
Akun layanan internet Anda telah *DIHAPUS OTOMATIS* karena tagihan tidak dibayar *{{bulan_nunggak}} bulan berturut-turut*.
Untuk berlangganan kembali, silakan *mendaftar dari awal*. Terima kasih.
— {{company}}`
};
}
router.get('/automation/template', requireAdminSession, (req, res) => {
try {
const key = String(req.query.key || '').trim();
const _bsvc = require('../services/billingSendService');
const defaults = waTemplateDefaults(company());
if (!Object.prototype.hasOwnProperty.call(defaults, key)) {
return res.status(400).json({ ok: false, error: 'key tidak dikenal' });
}
const text = db.getAppSetting(key, defaults[key] || '');
res.json({ ok: true, key: key, label: _bsvc.TEMPLATE_LABELS[key] || key, text: text });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.get('/automation', requireAdminSession, requireSidebarMenuAccess('automation'), (req, res) => {
const comp = company();
const defaultAutoBillingMsg =
'Yth. Pelanggan {{nama}},\n\n' +
'Ini adalah pengingat sebelum tanggal jatuh tempo/isolir.\n\n' +
'📦 *Paket:* {{paket}}\n' +
'💰 *Total Tagihan:* Rp {{tagihan}}\n' +
'📅 *Periode:* {{rincian}}\n\n' +
'Mohon segera melakukan pembayaran melalui portal pelanggan: {{link}}\n\n' +
'Terima kasih atas kerja samanya.\n' +
'Salam,\nAdmin ' + comp;
const _bsvc = require('../services/billingSendService');
const defaults = waTemplateDefaults(comp);
const selectedTplKey = String(getSetting('auto_billing_template_key', 'whatsapp_auto_billing_message') || 'whatsapp_auto_billing_message');
const autoBillingMsg = db.getAppSetting(selectedTplKey, defaults[selectedTplKey] || '');
let runs = [];
try { runs = db.prepare('SELECT * FROM automation_runs ORDER BY id DESC LIMIT 30').all(); } catch (e) {}
let isolateDays = [];
try { isolateDays = db.prepare("SELECT DISTINCT COALESCE(isolate_day,0) d FROM customers WHERE package_id IS NOT NULL AND COALESCE(isolate_day,0)>0 ORDER BY d").all().map(function (x) { return x.d; }); } catch (e) {}
res.render('admin/automation', {
title: 'Automation', company: comp, activePage: 'automation', msg: flashMsg(req),
getSetting, autoBillingMsg, selectedTplKey, billingTemplates: _bsvc.TEMPLATE_LABELS, runs: runs, isolateDays: isolateDays
});
});
router.post('/automation', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const b = req.body || {};
const truthy = (v) => (v === true || v === 'true' || v === '1' || v === 'on' || v === 1);
const next = {
whatsapp_auto_billing_enabled: !!b.enabled,
whatsapp_billing_to_customer_enabled: !!b.billing_enabled,
auto_deactivate_enabled: truthy(b.auto_deactivate_enabled),
auto_delete_overdue_enabled: truthy(b.auto_delete_overdue_enabled),
arrears_notice_enabled: truthy(b.arrears_notice_enabled),
autolunas_notify_enabled: truthy(b.autolunas_notify_enabled),
autolunas_recap_enabled: truthy(b.autolunas_recap_enabled),
};
if (b.auto_deactivate_months !== undefined && b.auto_deactivate_months !== '') {
next.auto_deactivate_months = parseInt(b.auto_deactivate_months) || 3;
}
const delay = b.delay ? parseInt(b.delay) : null;
if (delay != null && Number.isFinite(delay) && delay >= 1 && delay <= 60) next.whatsapp_broadcast_delay = delay;
if (b.reminder_days_before != null) {
const rDays = String(b.reminder_days_before).replace(/[^\d,]/g, '').replace(/,+/g, ',').replace(/^,|,$/g, '');
if (rDays) next.reminder_days_before = rDays;
}
if (b.auto_billing_template_key) next.auto_billing_template_key = String(b.auto_billing_template_key).trim();
const tplKey = String(b.auto_billing_template_key || 'whatsapp_auto_billing_message').trim();
const allowedKeys = Object.keys(waTemplateDefaults(company()));
const msgText = typeof b.message === 'string' ? b.message.trim() : '';
if (msgText && allowedKeys.includes(tplKey)) db.saveAppSetting(tplKey, msgText);
saveSettings(next);
req.session._msg = { type: 'success', text: 'Pengaturan otomatisasi berhasil disimpan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal menyimpan otomatisasi: ' + e.message };
}
res.redirect('/admin/automation');
});
router.post('/automation/send-now', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const svc = require('../services/billingSendService');
const r = await svc.start({
isolateDay: req.body.isolate_day,
templateKey: req.body.template_key,
intervalMode: req.body.interval_mode,
intervalValue: req.body.interval_value,
intervalMin: req.body.interval_min,
intervalMax: req.body.interval_max,
});
res.json({ ok: !!r.started, total: r.total || 0, label: r.label || '', message: r.started ? ('Pengiriman dimulai: ' + r.total + ' pelanggan (' + r.label + ').') : (r.message || 'Tidak ada target.') });
} catch (e) {
res.status(500).json({ ok: false, message: e.message });
}
});
router.get('/settings', requireAdminSession, requireSidebarMenuAccess('settings'), (req, res) => {
const settings = getSettings();
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.get('host');
const baseUrl = (settings && settings.app_url ? String(settings.app_url) : `${protocol}://${host}`).replace(/\/+$/, '');
const paymentWebhookUrl = `${baseUrl}/customer/payment/callback`;
res.render('admin/settings', {
title: 'Pengaturan Sistem', company: company(), activePage: 'settings',
settings, msg: flashMsg(req),
paymentWebhookUrl
});
});
router.get('/ewallet-logs', requireAdminSession, requireSidebarMenuAccess('settings'), (req, res) => {
const settings = getSettings();
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.get('host');
const baseUrl = (settings && settings.app_url ? String(settings.app_url) : `${protocol}://${host}`).replace(/\/+$/, '');
const paymentWebhookUrl = `${baseUrl}/customer/payment/callback`;
const _now = new Date();
const alMonth = parseInt(req.query.month) || (_now.getMonth() + 1);
const alYear = parseInt(req.query.year) || _now.getFullYear();
const alRows = db.prepare(`
SELECT i.amount, i.period_month, i.period_year, i.paid_at, c.name AS customer_name, c.customer_code
FROM invoices i JOIN customers c ON c.id = i.customer_id
WHERE i.paid_by_name = 'Sistem (Auto)' AND i.period_month = ? AND i.period_year = ?
ORDER BY c.name ASC
`).all(alMonth, alYear);
const alTotal = alRows.reduce(function (s, r) { return s + (Number(r.amount) || 0); }, 0);
res.render('admin/settings', {
title: 'Log Notifikasi E-Wallet (Webhook)', company: company(), activePage: 'ewallet_logs',
settings, msg: flashMsg(req),
paymentWebhookUrl,
viewMode: 'ewallet_logs',
alRows, alTotal, alMonth, alYear
});
});
router.post('/settings/qris-upload', requireAdminSession, qrisUpload.single('qris_file'), async (req, res) => {
try {
const f = req.file;
if (!f || !f.buffer || !f.originalname) throw new Error('File QRIS tidak ditemukan');
const ext = String(path.extname(f.originalname || '') || '').toLowerCase();
const allowedExt = new Set(['.png', '.jpg', '.jpeg', '.webp']);
const allowedMime = new Set(['image/png', 'image/jpeg', 'image/webp']);
if (!allowedExt.has(ext) || !allowedMime.has(String(f.mimetype || '').toLowerCase())) {
throw new Error('Format file tidak didukung. Gunakan PNG/JPG/WebP');
}
const dir = path.join(__dirname, '../public/uploads/qris');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const name = `qris-${Date.now()}-${crypto.randomBytes(6).toString('hex')}${ext}`;
const fullPath = path.join(dir, name);
fs.writeFileSync(fullPath, f.buffer);
const url = `/uploads/qris/${name}`;
let payload = '';
const payloadFromClient = String(req.body?.qris_payload || '').trim();
try {
if (payloadFromClient) {
payload = payloadFromClient.replace(/[\r\n\t]+/g, '').trim();
} else {
payload = await extractQrTextFromImageBuffer(f.buffer);
}
if (!payload.startsWith('000201')) payload = '';
} catch (e) {
payload = '';
}
const ok = saveSettings({ qris_static_qr_url: url, qris_static_enabled: true, ...(payload ? { qris_static_payload: payload } : {}) });
if (!ok) throw new Error('Gagal menyimpan pengaturan QRIS');
if (payload) {
req.session._msg = { type: 'success', text: 'QRIS berhasil di-upload. Payload QRIS berhasil terbaca otomatis.' };
} else {
req.session._msg = { type: 'success', text: 'QRIS berhasil di-upload, tetapi payload QRIS tidak terbaca otomatis. Silakan isi QRIS Static Payload (String) secara manual.' };
}
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal upload QRIS: ' + (e?.message || e) };
}
res.redirect('/admin/settings');
});
router.post('/settings/logo-upload', requireAdminSession, qrisUpload.single('logo_file'), async (req, res) => {
try {
const f = req.file;
if (!f || !f.buffer || !f.originalname) throw new Error('File logo tidak ditemukan');
const ext = String(path.extname(f.originalname || '') || '').toLowerCase();
const allowedExt = new Set(['.png', '.jpg', '.jpeg', '.webp']);
const allowedMime = new Set(['image/png', 'image/jpeg', 'image/webp']);
if (!allowedExt.has(ext) || !allowedMime.has(String(f.mimetype || '').toLowerCase())) {
throw new Error('Format file tidak didukung. Gunakan PNG/JPG/WebP');
}
const dir = path.join(__dirname, '../public/img');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const fullPath = path.join(dir, 'logo.png');
fs.writeFileSync(fullPath, f.buffer);
let _iconMsg = '';
try { await require('../services/iconService').generatePwaIcons(f.buffer); _iconMsg = ' Ikon PWA (192/512/maskable/apple) ikut diperbarui.'; }
catch (e) { try { logger.warn('[Logo] Gagal generate ikon PWA: ' + e.message); } catch (_) {} _iconMsg = ' (Ikon PWA gagal dibuat.)'; }
try { saveSettings({ logo_version: String(Date.now()) }); } catch (e) {}
req.session._msg = { type: 'success', text: 'Logo aplikasi berhasil diperbarui!' + _iconMsg };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal upload logo: ' + (e?.message || e) };
}
res.redirect('/admin/settings');
});
router.post('/settings/branding/logo/:type', requireAdminSession, qrisUpload.single('logo_file'), async (req, res) => {
try {
const type = String(req.params.type || '').toLowerCase();
const map = {
portal: { file: 'logo-portal.png', key: 'logo_portal', url: '/img/logo-portal.png' },
invoice: { file: 'logo-invoice.png', key: 'logo_invoice', url: '/img/logo-invoice.png' },
pwa: { file: 'logo-pwa.png', key: 'logo_pwa', url: '/img/logo-pwa.png' }
};
// Khusus foto hero landing: konversi ke WebP + JPEG (fallback)
if (type === 'hero') {
const f0 = req.file;
if (!f0 || !f0.buffer) throw new Error('File foto tidak ditemukan');
const dirH = path.join(__dirname, '../public/img/landing');
if (!fs.existsSync(dirH)) fs.mkdirSync(dirH, { recursive: true });
const r = await require('../services/imageOptimize').writeWebpAndJpg(f0.buffer, path.join(dirH, 'server-1600'), { maxWidth: 1600, quality: 82 });
try { saveSettings({ landing_hero_version: String(Date.now()) }); } catch (e) {}
req.session._msg = { type: 'success', text: 'Foto hero landing diperbarui (WebP ' + Math.round(r.webp / 1024) + ' KB + JPEG ' + Math.round(r.jpg / 1024) + ' KB).' };
return res.redirect('/admin/settings');
}
const cfg = map[type];
if (!cfg) throw new Error('Jenis logo tidak dikenal');
const f = req.file;
if (!f || !f.buffer) throw new Error('File logo tidak ditemukan');
const ext = String(path.extname(f.originalname || '') || '').toLowerCase();
const allowedExt = new Set(['.png', '.jpg', '.jpeg', '.webp']);
const allowedMime = new Set(['image/png', 'image/jpeg', 'image/webp']);
if (!allowedExt.has(ext) || !allowedMime.has(String(f.mimetype || '').toLowerCase())) throw new Error('Format tidak didukung (PNG/JPG/WebP)');
const dir = path.join(__dirname, '../public/img');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, cfg.file), f.buffer);
const upd = {}; upd[cfg.key] = cfg.url; upd.logo_version = String(Date.now());
try { saveSettings(upd); } catch (e) {}
let extra = '';
if (type === 'pwa') {
try { await require('../services/iconService').generatePwaIcons(f.buffer); extra = ' Ikon PWA (192/512/maskable/apple) dibuat ulang.'; }
catch (e) { extra = ' (Ikon PWA gagal dibuat.)'; }
}
req.session._msg = { type: 'success', text: 'Logo ' + type + ' berhasil diperbarui.' + extra };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal upload logo: ' + (e && e.message ? e.message : e) };
}
res.redirect('/admin/settings');
});
router.post('/settings/regenerate-icons', requireAdminSession, async (req, res) => {
try {
const p = path.join(__dirname, '../public/img/logo.png');
if (!fs.existsSync(p)) return res.json({ ok: false, message: 'Logo belum ada. Upload logo dulu.' });
await require('../services/iconService').generatePwaIcons(fs.readFileSync(p));
try { saveSettings({ logo_version: String(Date.now()) }); } catch (e) {}
return res.json({ ok: true, message: 'Ikon PWA berhasil dibuat dari logo saat ini.' });
} catch (e) { return res.json({ ok: false, message: 'Gagal generate ikon: ' + e.message }); }
});
router.get('/update', requireAdminSession, requireSidebarMenuAccess('update'), restrictToAdmin, (req, res) => {
const repoRoot = path.resolve(__dirname, '..');
const info = getUpdateInfo(repoRoot);
res.render('admin/update', {
title: 'Update Aplikasi',
company: company(),
activePage: 'update',
msg: flashMsg(req),
info,
log: popUpdateLog(req)
});
});
router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
const repoRoot = path.resolve(__dirname, '..');
const log = [];
const pushCmd = (label, r) => {
log.push(`$ ${label}`.trim());
if (r.stdout) log.push(String(r.stdout).trimEnd());
if (r.stderr) log.push(String(r.stderr).trimEnd());
};
const versionPath = path.join(repoRoot, 'version.txt');
const localBefore = readTextFileSafe(versionPath) || '-';
const branch = getGitDefaultBranch(repoRoot);
const backupRoot = path.join(os.tmpdir(), `billing-update-backup-${Date.now()}`);
const authFolder = String(getSetting('whatsapp_auth_folder', 'auth_info_baileys') || 'auth_info_baileys').trim() || 'auth_info_baileys';
const preservedEntries = [
{ label: 'settings.json', relativePath: 'settings.json', cleanExclude: 'settings.json' },
{ label: '.env', relativePath: '.env', cleanExclude: '.env' },
{ label: 'database', relativePath: 'database', cleanExclude: 'database' },
{ label: 'public/uploads', relativePath: 'public/uploads', cleanExclude: 'public/uploads' },
{ label: 'public/img', relativePath: 'public/img', cleanExclude: 'public/img' },
{ label: authFolder, relativePath: authFolder, cleanExclude: authFolder.replace(/\\/g, '/') },
{ label: 'data', relativePath: 'data', cleanExclude: 'data' }
];
let repoChanged = false;
const backupPreservedEntries = () => {
const backedUp = [];
for (const entry of preservedEntries) {
const sourcePath = path.resolve(repoRoot, entry.relativePath);
const backupPath = path.resolve(backupRoot, entry.relativePath);
if (!fs.existsSync(sourcePath)) continue;
const stat = fs.statSync(sourcePath);
if (stat.isDirectory()) {
fs.mkdirSync(backupPath, { recursive: true });
copyDirSync(sourcePath, backupPath);
} else {
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
fs.copyFileSync(sourcePath, backupPath);
}
backedUp.push(entry.label);
}
if (backedUp.length > 0) {
log.push(`$ backup preserved files: ${backedUp.join(', ')}`);
}
};
const restorePreservedFiles = (stageLabel) => {
const restored = [];
for (const entry of preservedEntries) {
const targetPath = path.resolve(repoRoot, entry.relativePath);
const backupPath = path.resolve(backupRoot, entry.relativePath);
if (!fs.existsSync(backupPath)) continue;
const stat = fs.statSync(backupPath);
if (stat.isDirectory()) {
fs.rmSync(targetPath, { recursive: true, force: true });
fs.mkdirSync(targetPath, { recursive: true });
copyDirSync(backupPath, targetPath);
} else {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.copyFileSync(backupPath, targetPath);
}
restored.push(entry.label);
}
if (restored.length > 0) {
log.push(`$ restore preserved files (${stageLabel}): ${restored.join(', ')}`);
}
};
try {
const inside = runCmd('git', ['rev-parse', '--is-inside-work-tree'], repoRoot);
pushCmd('git rev-parse --is-inside-work-tree', inside);
if (!inside.ok) throw new Error('Folder ini belum menjadi git repository.');
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
info.repo = _repo || 'origin';
const fetch = _repo
? runCmd('git', ['fetch', '--prune', 'https://github.com/' + _repo + '.git', branch], repoRoot)
: runCmd('git', ['fetch', '--prune'], repoRoot);
pushCmd('git fetch --prune', fetch);
if (!fetch.ok) throw new Error('Gagal git fetch.');
const remote = _repo
? runCmd('git', ['show', 'FETCH_HEAD:version.txt'], repoRoot)
: runCmd('git', ['show', `origin/${branch}:version.txt`], repoRoot);
pushCmd(`git show origin/${branch}:version.txt`, remote);
if (!remote.ok) throw new Error('Tidak bisa membaca version.txt dari GitHub.');
const remoteVersion = String(remote.stdout || '').trim() || '-';
if (remoteVersion !== '-' && remoteVersion === localBefore) {
req.session._msg = { type: 'success', text: 'Versi sudah terbaru: ' + localBefore };
req.session._updateLog = log.join('\n');
return res.redirect('/admin/update');
}
fs.mkdirSync(backupRoot, { recursive: true });
backupPreservedEntries();
const resetSettings = runCmd('git', ['checkout', '--', 'settings.json'], repoRoot);
pushCmd('git checkout -- settings.json', resetSettings);
const resetDb = runCmd('git', ['checkout', '--', 'database'], repoRoot);
pushCmd('git checkout -- database', resetDb);
const resetHard = runCmd('git', ['reset', '--hard', _repo ? 'FETCH_HEAD' : `origin/${branch}`], repoRoot);
pushCmd(`git reset --hard origin/${branch}`, resetHard);
if (!resetHard.ok) throw new Error('Gagal reset ke origin/' + branch);
repoChanged = true;
if (remoteVersion && remoteVersion !== '-') {
try {
fs.writeFileSync(versionPath, remoteVersion + os.EOL, 'utf8');
log.push(`$ write version.txt = ${remoteVersion}`);
} catch (e) {
log.push(`$ write version.txt failed: ${String(e?.message || e)}`);
}
}
const cleanExcludes = ['node_modules', 'package-lock.json', ...preservedEntries.map((entry) => entry.cleanExclude)];
const cleanArgs = ['clean', '-fd'];
for (const exclude of cleanExcludes) {
cleanArgs.push('-e', exclude);
}
const clean = runCmd(
'git',
cleanArgs,
repoRoot
);
pushCmd(`git ${cleanArgs.join(' ')}`, clean);
restorePreservedFiles('post-update');
// Cek apakah package.json berubah sebelum install
const pkgDiff = runCmd('git', ['diff', 'HEAD@{1}', 'HEAD', '--', 'package.json'], repoRoot);
const pkgChanged = pkgDiff.ok && String(pkgDiff.stdout || '').trim().length > 0;
if (pkgChanged) {
log.push('$ package.json berubah, menjalankan npm install (Low-CPU Mode)...');
const npm = runCmd('npm', ['install', '--omit=dev', '--no-audit', '--no-fund', '--prefer-offline'], repoRoot);
pushCmd('npm install --omit=dev --no-audit --no-fund --prefer-offline', npm);
if (!npm.ok) throw new Error('Update berhasil, tetapi npm install gagal.');
} else {
log.push('$ package.json tidak berubah, npm install dilewati (Hemat CPU & Waktu!).');
}
const localAfter = readTextFileSafe(versionPath) || '-';
req.session._msg = { type: 'success', text: `Update selesai. Versi: ${localBefore} → ${localAfter}. Silakan restart aplikasi.` };
req.session._updateLog = log.join('\n');
} catch (e) {
if (repoChanged) {
try {
restorePreservedFiles('error-recovery');
} catch (restoreErr) {
log.push(`$ restore preserved files failed: ${String(restoreErr?.message || restoreErr)}`);
}
}
req.session._msg = { type: 'error', text: 'Gagal update: ' + (e?.message || e) };
req.session._updateLog = log.join('\n');
} finally {
try {
if (fs.existsSync(backupRoot)) fs.rmSync(backupRoot, { recursive: true, force: true });
} catch (e) {}
}
return res.redirect('/admin/update');
});
router.post('/api/telegram/sync', requireAdminSession, async (req, res) => {
try {
const { initTelegram } = require('../services/telegramBot');
initTelegram();
res.json({ success: true, message: 'Bot Telegram berhasil disinkronkan.' });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/settings', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const newSettings = { ...req.body };
if (newSettings.whatsapp_enabled === 'true') newSettings.whatsapp_enabled = true;
else if (newSettings.whatsapp_enabled === 'false') newSettings.whatsapp_enabled = false;
if (newSettings.voucher_enabled === 'true') newSettings.voucher_enabled = true;
else if (newSettings.voucher_enabled === 'false') newSettings.voucher_enabled = false;
if (newSettings.qris_static_enabled === 'true') newSettings.qris_static_enabled = true;
else if (newSettings.qris_static_enabled === 'false') newSettings.qris_static_enabled = false;
if (newSettings.payment_bank_enabled === 'true') newSettings.payment_bank_enabled = true;
else if (newSettings.payment_bank_enabled === 'false') newSettings.payment_bank_enabled = false;
if (newSettings.payment_ewallet_enabled === 'true') newSettings.payment_ewallet_enabled = true;
else if (newSettings.payment_ewallet_enabled === 'false') newSettings.payment_ewallet_enabled = false;
if (newSettings.auto_delete_overdue_enabled === 'true') newSettings.auto_delete_overdue_enabled = true;
else if (newSettings.auto_delete_overdue_enabled === 'false') newSettings.auto_delete_overdue_enabled = false;
if (newSettings.arrears_notice_enabled === 'true') newSettings.arrears_notice_enabled = true;
else if (newSettings.arrears_notice_enabled === 'false') newSettings.arrears_notice_enabled = false;
if (newSettings.webhook_unmatched_notify_enabled === 'true') newSettings.webhook_unmatched_notify_enabled = true;
else if (newSettings.webhook_unmatched_notify_enabled === 'false') newSettings.webhook_unmatched_notify_enabled = false;
if (newSettings.autolunas_notify_enabled === 'true') newSettings.autolunas_notify_enabled = true;
else if (newSettings.autolunas_notify_enabled === 'false') newSettings.autolunas_notify_enabled = false;
if (newSettings.autolunas_recap_enabled === 'true') newSettings.autolunas_recap_enabled = true;
else if (newSettings.autolunas_recap_enabled === 'false') newSettings.autolunas_recap_enabled = false;
if (newSettings.tripay_enabled === 'true') newSettings.tripay_enabled = true;
else if (newSettings.tripay_enabled === 'false') newSettings.tripay_enabled = false;
if (newSettings.midtrans_enabled === 'true') newSettings.midtrans_enabled = true;
else if (newSettings.midtrans_enabled === 'false') newSettings.midtrans_enabled = false;
if (newSettings.xendit_enabled === 'true') newSettings.xendit_enabled = true;
else if (newSettings.xendit_enabled === 'false') newSettings.xendit_enabled = false;
if (newSettings.duitku_enabled === 'true') newSettings.duitku_enabled = true;
else if (newSettings.duitku_enabled === 'false') newSettings.duitku_enabled = false;
if (newSettings.default_gateway) newSettings.default_gateway = newSettings.default_gateway.toLowerCase();
if (typeof newSettings.whatsapp_admin_numbers === 'string') {
newSettings.whatsapp_admin_numbers = newSettings.whatsapp_admin_numbers.split(',').map(n => n.trim()).filter(Boolean);
}
// whatsapp_tech_numbers removed - now automatically fetched from technicians table
if (newSettings.server_port) newSettings.server_port = parseInt(newSettings.server_port);
if (newSettings.mikrotik_port) newSettings.mikrotik_port = parseInt(newSettings.mikrotik_port);
if (newSettings.whatsapp_broadcast_delay) newSettings.whatsapp_broadcast_delay = parseInt(newSettings.whatsapp_broadcast_delay);
newSettings.login_otp_enabled = (newSettings.login_otp_enabled === 'true');
newSettings.telegram_enabled = (newSettings.telegram_enabled === 'true');
newSettings.auto_backup_enabled = (newSettings.auto_backup_enabled === 'true');
if (newSettings.auto_deactivate_enabled !== undefined) newSettings.auto_deactivate_enabled = (newSettings.auto_deactivate_enabled === 'true');
if (newSettings.auto_deactivate_months !== undefined) newSettings.auto_deactivate_months = parseInt(newSettings.auto_deactivate_months) || 3;
newSettings.use_builtin_acs = (newSettings.use_builtin_acs === 'true' || newSettings.use_builtin_acs === true);
// Multi-Router Mode settings
if (!newSettings.multi_router_mode) newSettings.multi_router_mode = 'disabled';
if (newSettings.default_router_id) newSettings.default_router_id = parseInt(newSettings.default_router_id) || null;
// ✅ NEW: Jika switching dari disabled ke active, auto-assign default router ke pelanggan NULL
const oldMode = getSetting('multi_router_mode', 'disabled');
if (oldMode === 'disabled' && newSettings.multi_router_mode === 'active') {
const defaultRouterId = newSettings.default_router_id;
if (defaultRouterId && defaultRouterId > 0) {
// Auto-assign default router to customers with NULL router_id
const result = db.prepare(
"UPDATE customers SET router_id = ? WHERE router_id IS NULL AND (pppoe_username != '' OR hotspot_username != '' OR static_ip != '')"
).run(defaultRouterId);
logger.info(`[Settings] Auto-assigned router ${defaultRouterId} to ${result.changes} customers with NULL router_id`);
} else {
// Jika tidak ada default router, cari router pertama yang aktif
const router = db.prepare('SELECT id FROM routers WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
if (router) {
const result = db.prepare(
"UPDATE customers SET router_id = ? WHERE router_id IS NULL AND (pppoe_username != '' OR hotspot_username != '' OR static_ip != '')"
).run(router.id);
logger.info(`[Settings] Auto-assigned router ${router.id} to ${result.changes} customers with NULL router_id`);
}
}
}
const success = saveSettings(newSettings);
if (success) {
// Re-init services if needed
if (newSettings.telegram_enabled) {
require('../services/telegramBot').initTelegram();
} else {
require('../services/telegramBot').initTelegram(); // This will stop it if it was running
}
req.session._msg = { type: 'success', text: 'Pengaturan berhasil disimpan.' };
} else {
req.session._msg = { type: 'error', text: 'Gagal menyimpan pengaturan' };
}
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/settings');
});
// ─── BACKUP & RECOVERY ──────────────────────────────────────────────────────
router.get('/payment-unmatched', requireAdminSession, (req, res) => {
res.redirect('/admin/ewallet-logs');
});
router.post('/payment-unmatched/:id/match', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const notifId = parseInt(req.params.id);
const invId = parseInt(req.body.invoice_id || req.body.manual_invoice_id);
if (!invId) throw new Error('Pilih invoice kandidat atau isi ID invoice');
const inv = db.prepare('SELECT * FROM invoices WHERE id = ?').get(invId);
if (!inv) throw new Error('Invoice #' + invId + ' tidak ditemukan');
if (String(inv.status) === 'paid') throw new Error('Invoice #' + invId + ' sudah lunas');
const note = 'DICocokkan MANUAL dari notif webhook #' + notifId;
billingSvc.markAsPaid(invId, 'Manual (Webhook)', note, req.session.admin || null);
try { db.prepare('UPDATE webhook_payment_notifs SET matched_invoice_id = ? WHERE id = ?').run(invId, notifId); } catch (e) {}
// Aktifkan kembali bila pelanggan isolir & tak ada unpaid
try {
const cust = customerSvc.getCustomerById(inv.customer_id);
if (cust && String(cust.status) === 'suspended') {
const unpaid = db.prepare("SELECT COUNT(*) c FROM invoices WHERE customer_id = ? AND status='unpaid'").get(inv.customer_id).c;
if (!unpaid) await customerSvc.activateCustomer(inv.customer_id);
}
} catch (e) {}
req.session._msg = { type: 'success', text: 'Invoice #' + invId + ' ditandai LUNAS & notif dicocokkan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal cocokkan: ' + e.message };
}
res.redirect('/admin/payment-unmatched');
});
router.post('/payment-unmatched/:id/dismiss', requireAdminSession, (req, res) => {
try {
db.prepare('UPDATE webhook_payment_notifs SET dismissed = 1 WHERE id = ?').run(parseInt(req.params.id));
req.session._msg = { type: 'success', text: 'Notifikasi diabaikan.' };
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
res.redirect('/admin/payment-unmatched');
});
router.get('/autolunas', requireAdminSession, requireSidebarMenuAccess('payment_unmatched'), (req, res) => {
res.redirect('/admin/payment-unmatched?tab=autolunas');
});
router.get('/autolunas/export', requireAdminSession, requireSidebarMenuAccess('payment_unmatched'), (req, res) => {
const now = new Date();
const month = parseInt(req.query.month) || (now.getMonth() + 1);
const year = parseInt(req.query.year) || now.getFullYear();
const rows = db.prepare(`
SELECT i.amount, i.period_month, i.period_year, i.paid_at, i.notes, c.name AS customer_name, c.customer_code
FROM invoices i JOIN customers c ON c.id = i.customer_id
WHERE i.paid_by_name = 'Sistem (Auto)' AND i.period_month = ? AND i.period_year = ?
ORDER BY c.name ASC
`).all(month, year);
const esc = (v) => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"';
const lines = [['No','Pelanggan','ID Pelanggan','Periode','Nominal','Waktu Lunas','Catatan'].map(esc).join(',')];
rows.forEach((r, i) => lines.push([i + 1, r.customer_name, r.customer_code || '', r.period_month + '/' + r.period_year, Number(r.amount) || 0, r.paid_at || '', String(r.notes || '').replace('AUTO-LUNAS:', '').trim()].map(esc).join(',')));
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="rekap-autolunas-' + month + '-' + year + '.csv"');
res.send('\uFEFF' + lines.join('\n'));
});
router.get('/backup', requireAdminSession, requireSidebarMenuAccess('backup'), (req, res) => {
const result = backupSvc.listBackups();
res.render('admin/backup', {
title: 'Backup & Recovery',
company: company(),
activePage: 'backup',
msg: flashMsg(req),
backups: result.backups || [],
total: result.total || 0,
getSetting
});
});
router.get('/backup/download/:fileName', requireAdminSession, (req, res) => {
try {
const rawFileName = req.params.fileName;
const fileName = path.basename(rawFileName);
const backupDir = path.join(__dirname, '../backups');
const filePath = path.join(backupDir, fileName);
if (!fs.existsSync(filePath)) {
req.session._msg = { type: 'error', text: 'File backup tidak ditemukan.' };
return res.redirect('/admin/backup');
}
res.download(filePath, fileName, (err) => {
if (err && !res.headersSent) {
logger.error(`[Backup] Error downloading file ${fileName}:`, err);
req.session._msg = { type: 'error', text: 'Gagal mendownload file backup.' };
res.redirect('/admin/backup');
}
});
} catch (e) {
logger.error('[Backup] Download error:', e);
req.session._msg = { type: 'error', text: 'Gagal mendownload backup: ' + e.message };
res.redirect('/admin/backup');
}
});
router.post('/backup/create', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const { type } = req.body;
let result;
if (type === 'all') {
result = backupSvc.backupAll();
} else if (type === 'database') {
result = backupSvc.backupDatabase();
} else if (type === 'settings') {
result = backupSvc.backupSettings();
} else {
req.session._msg = { type: 'error', text: 'Tipe backup tidak valid' };
return res.redirect('/admin/backup');
}
if (result.success) {
req.session._msg = { type: 'success', text: `Backup berhasil dibuat: ${result.fileName}` };
} else {
req.session._msg = { type: 'error', text: `Gagal backup: ${result.error}` };
}
} catch (e) {
req.session._msg = { type: 'error', text: `Gagal: ${e.message}` };
}
res.redirect('/admin/backup');
});
router.post('/backup/restore', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
let restartNeeded = false;
try {
const { fileName, type } = req.body;
let result;
if (type === 'database') {
result = backupSvc.restoreDatabase(fileName);
} else if (type === 'settings') {
result = backupSvc.restoreSettings(fileName);
} else {
req.session._msg = { type: 'error', text: 'Tipe restore tidak valid' };
return res.redirect('/admin/backup');
}
if (result.success) {
if (type === 'database') {
restartNeeded = true;
req.session._msg = { type: 'success', text: `Restore database berhasil: ${fileName}. Aplikasi restart otomatis untuk menerapkan...` };
} else {
req.session._msg = { type: 'success', text: `Restore berhasil: ${fileName}` };
}
} else {
req.session._msg = { type: 'error', text: `Gagal restore: ${result.error}` };
}
} catch (e) {
req.session._msg = { type: 'error', text: `Gagal: ${e.message}` };
}
res.redirect('/admin/backup');
// DB di-swap: koneksi lama masih memakai file lama -> muat ulang proses (PM2/Docker restart otomatis)
if (restartNeeded) setTimeout(() => process.exit(0), 2000);
});
router.post('/backup/upload-restore', requireAdminSession, upload.single('backupFile'), (req, res) => {
let restartNeeded = false;
try {
const file = req.file;
if (!file || !file.buffer || !file.originalname) {
throw new Error('File backup tidak ditemukan.');
}
const originalName = path.basename(file.originalname);
const ext = path.extname(originalName).toLowerCase();
const timestamp = Date.now();
const backupDir = path.join(__dirname, '../backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
let result;
let savedFileName = '';
if (ext === '.db' || ext === '.sqlite' || originalName.includes('billing_db')) {
savedFileName = `uploaded_db_${timestamp}_${originalName}`;
const savePath = path.join(backupDir, savedFileName);
fs.writeFileSync(savePath, file.buffer);
result = backupSvc.restoreDatabase(savedFileName);
restartNeeded = true;
} else if (ext === '.json' || originalName.includes('settings')) {
savedFileName = `uploaded_settings_${timestamp}_${originalName}`;
const savePath = path.join(backupDir, savedFileName);
fs.writeFileSync(savePath, file.buffer);
// Verify JSON validity before restoring settings
try {
JSON.parse(file.buffer.toString('utf8'));
} catch (jsonErr) {
fs.unlinkSync(savePath);
throw new Error('Format JSON file settings tidak valid: ' + jsonErr.message);
}
result = backupSvc.restoreSettings(savedFileName);
} else {
throw new Error('Format file tidak didukung. Harap upload file .db (database) atau .json (settings).');
}
if (result && result.success) {
req.session._msg = {
type: 'success',
text: `File backup "${originalName}" berhasil di-upload dan di-restore! Backup otomatis sebelum restore telah dibuat (${result.preRestoreBackup || '-'}).`
};
} else {
req.session._msg = { type: 'error', text: `Gagal restore: ${result ? result.error : 'Error tidak diketahui'}` };
}
} catch (e) {
logger.error('[Backup] Upload & restore error:', e);
req.session._msg = { type: 'error', text: 'Gagal upload & restore: ' + e.message };
}
res.redirect('/admin/backup');
if (restartNeeded && result && result.success) setTimeout(() => process.exit(0), 2000);
});
router.post('/backup/delete', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const { fileName } = req.body;
const fs = require('fs');
const path = require('path');
const backupDir = path.join(__dirname, '../backups');
const backupFilePath = path.join(backupDir, fileName);
if (!fs.existsSync(backupFilePath)) {
req.session._msg = { type: 'error', text: 'File backup tidak ditemukan' };
return res.redirect('/admin/backup');
}
fs.unlinkSync(backupFilePath);
logger.info(`[Backup] Backup deleted: ${fileName}`);
req.session._msg = { type: 'success', text: `Backup berhasil dihapus: ${fileName}` };
} catch (e) {
req.session._msg = { type: 'error', text: `Gagal menghapus: ${e.message}` };
}
res.redirect('/admin/backup');
});
router.post('/backup/cleanup', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const { retentionDays } = req.body;
const result = backupSvc.cleanupOldBackups(parseInt(retentionDays) || 30);
if (result.success) {
req.session._msg = { type: 'success', text: `Cleanup selesai: ${result.deletedCount} backup lama dihapus` };
} else {
req.session._msg = { type: 'error', text: `Gagal cleanup: ${result.error}` };
}
} catch (e) {
req.session._msg = { type: 'error', text: `Gagal: ${e.message}` };
}
res.redirect('/admin/backup');
});
// ─── INVENTORY / WAREHOUSE ──────────────────────────────────────────────────
router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventory'), (req, res) => {
const items = inventorySvc.getAllItems(req.query.q);
const categories = inventorySvc.getAllCategories();
const logs = inventorySvc.getInventoryLogs(100);
res.render('admin/inventory', {
title: 'Manajemen Inventaris',
company: company(),
activePage: 'inventory',
msg: flashMsg(req),
items,
categories,
logs
});
});
router.post('/inventory/category/add', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
inventorySvc.createCategory(req.body);
req.session._msg = { type: 'success', text: 'Kategori berhasil ditambahkan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/inventory');
});
router.post('/inventory/category/delete/:id', requireAdminSession, (req, res) => {
try {
inventorySvc.deleteCategory(req.params.id);
req.session._msg = { type: 'success', text: 'Kategori berhasil dihapus.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/inventory');
});
router.post('/inventory/item/add', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
inventorySvc.createItem(req.body);
req.session._msg = { type: 'success', text: 'Barang berhasil ditambahkan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/inventory');
});
router.post('/inventory/item/edit/:id', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
inventorySvc.updateItem(req.params.id, req.body);
req.session._msg = { type: 'success', text: 'Barang berhasil diperbarui.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/inventory');
});
router.post('/inventory/stock/add', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
inventorySvc.addStock(req.body, req.session.adminUser || 'Admin');
req.session._msg = { type: 'success', text: 'Stok berhasil ditambahkan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/inventory');
});
router.get('/audit-logs', requireAdminSession, requireSidebarMenuAccess('audit_logs'), restrictToAdmin, (req, res) => {
const filters = {
action: req.query.action || null,
entity_type: req.query.entity_type || null,
limit: 100
};
const logs = auditSvc.getAuditTrail(filters);
const stats = auditSvc.getAuditStats();
res.render('admin/audit_logs', {
title: 'Audit Trail / Log Aktivitas',
company: company(),
activePage: 'audit_logs',
logs,
stats,
filters
});
});
// ─── MONITORING ──────────────────────────────────────────────────────────────
router.get('/monitoring', requireAdminSession, requireSidebarMenuAccess('monitoring'), restrictToAdmin, async (req, res) => {
const healthStatus = monitoringSvc.getHealthStatus();
const performanceSummary = monitoringSvc.getPerformanceSummary();
const dependencies = await diagnosticsSvc.checkDependencies();
const recentErrors = diagnosticsSvc.getRecentErrors(10);
const settings = getSettings(); // Get current settings
res.render('admin/monitoring', {
title: 'Monitoring Sistem',
company: company(),
activePage: 'monitoring',
healthStatus,
performanceSummary,
dependencies,
recentErrors,
settings // Pass settings to view
});
});
router.get('/api/health', requireAdmin, (req, res) => {
const healthStatus = monitoringSvc.getHealthStatus();
res.json(healthStatus);
});
router.get('/api/metrics', requireAdmin, (req, res) => {
const metrics = monitoringSvc.getAllMetrics();
res.json(metrics);
});
router.get('/api/metrics/history', requireAdmin, (req, res) => {
const limit = parseInt(req.query.limit) || 10;
const history = monitoringSvc.getMetricsHistory(limit);
res.json(history);
});
// ─── GENIEACS SETTINGS API ──────────────────────────────────────────────────
router.post('/api/genieacs/settings', requireAdmin, async (req, res) => {
try {
const { genieacs_timeout, genieacs_rxpower_threshold, genieacs_monitoring_interval, genieacs_monitoring_enabled } = req.body;
// Validate input
if (genieacs_timeout < 5000 || genieacs_timeout > 120000) {
return res.json({ success: false, message: 'Timeout harus antara 5000-120000 ms' });
}
if (genieacs_rxpower_threshold < -40 || genieacs_rxpower_threshold > -15) {
return res.json({ success: false, message: 'RX Power threshold harus antara -40 sampai -15 dBm' });
}
if (genieacs_monitoring_interval < 1 || genieacs_monitoring_interval > 24) {
return res.json({ success: false, message: 'Monitoring interval harus antara 1-24 jam' });
}
// Update settings
const currentSettings = getSettings();
currentSettings.genieacs_timeout = parseInt(genieacs_timeout);
currentSettings.genieacs_rxpower_threshold = parseFloat(genieacs_rxpower_threshold);
currentSettings.genieacs_monitoring_interval = parseInt(genieacs_monitoring_interval);
currentSettings.genieacs_monitoring_enabled = Boolean(genieacs_monitoring_enabled);
// Save to file
const fs = require('fs');
const path = require('path');
const settingsPath = require('../config/paths').settingsPath;
fs.writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2), 'utf8');
// Log audit
try {
if (auditSvc && typeof auditSvc.logAuditTrail === 'function') {
auditSvc.logAuditTrail({
action: 'UPDATE',
entity_type: 'genieacs_settings',
entity_id: 'settings.json',
actor_type: req.session.isAdmin ? 'admin' : 'cashier',
actor_id: req.session.adminUser || req.session.cashierUsername || 'admin',
actor_name: req.session.adminUser || req.session.cashierName || 'Admin',
details: {
timeout: genieacs_timeout,
rxpower_threshold: genieacs_rxpower_threshold,
monitoring_interval: genieacs_monitoring_interval,
monitoring_enabled: genieacs_monitoring_enabled
},
ip_address: req.ip || req.connection.remoteAddress,
user_agent: req.headers['user-agent']
});
} else {
logger.warn('[API] auditSvc.logAuditTrail is not available');
}
} catch (auditError) {
logger.error('[API] Error logging audit trail:', auditError);
}
res.json({
success: true,
message: 'Pengaturan GenieACS berhasil disimpan. Restart aplikasi untuk menerapkan perubahan timeout.'
});
} catch (error) {
logger.error('[API] Error saving GenieACS settings:', error);
res.json({ success: false, message: 'Error: ' + error.message });
}
});
router.get('/api/genieacs/test', requireAdmin, async (req, res) => {
try {
const genieacs = require('../config/genieacs');
const devices = await genieacs.getDevices();
res.json({
success: true,
message: 'Koneksi ke GenieACS berhasil!',
deviceCount: devices.length
});
} catch (error) {
logger.error('[API] Error testing GenieACS connection:', error);
res.json({
success: false,
message: 'Koneksi gagal: ' + error.message
});
}
});
// ─── API ROUTES (existing) ──────────────────────────────────────────────────
router.get('/api/stats', requireAdmin, async (req, res) => {
try {
const result = await customerDevice.listAllDevices(999999);
if (!result.ok) return res.json({ error: result.message });
const mikrotikService = require('../services/mikrotikService');
const activeSessionsMap = await mikrotikService.getActivePppoeSessionsMap().catch(() => new Map());
const devices = result.devices;
const total = devices.length;
let online = 0, offline = 0;
devices.forEach(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 status = String(mapped.status || 'offline').toLowerCase();
if (status === 'online') online++;
else offline++;
});
res.json({ total, online, offline, warning: 0, lastUpdate: getNowLocalISO() });
} catch (e) {
res.status(500).json({ error: 'Failed to get stats', detail: e.message });
}
});
router.get('/api/devices', requireAdmin, async (req, res) => {
try {
const { search, status, limit = 999999, offset = 0 } = req.query;
const result = await customerDevice.listAllDevices(999999);
if (!result.ok) return res.json({ error: result.message });
const mikrotikService = require('../services/mikrotikService');
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 tagsArr = Array.isArray(d._tags) ? d._tags.filter(Boolean).map(String) : [];
return {
id: String(d._id || ''),
tags: tagsArr,
serialNumber: String(mapped.serialNumber || '-'),
lastInform: d._lastInform,
lastInformLabel: String(mapped.lastInform || '-'),
lastInformAgo: String(mapped.lastInformAgo || '-'),
lastSync: d._updatedAt || mapped.lastSyncRaw || d._lastInform || '',
lastSyncLabel: String(mapped.lastSync || '-'),
lastSyncAgo: String(mapped.lastSyncAgo || '-'),
syncInProgress: !!mapped.syncInProgress,
syncPendingCount: Number(mapped.syncPendingCount || 0),
syncStatusLabel: String(mapped.syncStatusLabel || 'Idle'),
status: String(mapped.status || 'unknown').toLowerCase(),
pppoeIP: String(mapped.pppoeIP || '-'),
pppoeUsername: String(mapped.pppoeUsername || '-'),
rxPower: String(mapped.rxPower || '-'),
uptime: String(mapped.uptime || '-'),
model: String(mapped.model || '-'),
softwareVersion: String(mapped.softwareVersion || '-'),
userConnected: mapped.totalAssociations ?? '-',
ssid: String(mapped.ssid || '-'),
acs_server_id: d._acs_server_id || 'legacy',
acs_server_name: d._acs_server_name || 'Default ACS'
};
});
if (search) {
const s = search.toLowerCase();
const billingCustomers = customerSvc.getAllCustomers(s);
const matchingTags = new Set(billingCustomers.map(c => c.genieacs_tag?.toLowerCase()).filter(Boolean));
const matchingPppoes = new Set(billingCustomers.map(c => c.pppoe_username?.toLowerCase()).filter(Boolean));
devices = devices.filter(d =>
String(d.id || '').toLowerCase().includes(s) ||
(Array.isArray(d.tags) && d.tags.some(t => String(t || '').toLowerCase().includes(s) || matchingTags.has(String(t || '').toLowerCase()))) ||
String(d.serialNumber || '').toLowerCase().includes(s) ||
String(d.pppoeIP || '').toLowerCase().includes(s) ||
(String(d.pppoeUsername || '') !== 'N/A' && String(d.pppoeUsername || '').toLowerCase().includes(s)) ||
matchingPppoes.has(String(d.pppoeUsername || '').toLowerCase())
);
}
if (status && status !== 'all') devices = devices.filter(d => d.status === status);
const total = devices.length;
const paginated = devices.slice(parseInt(offset), parseInt(offset) + parseInt(limit));
res.json({ devices: paginated, total, limit: parseInt(limit), offset: parseInt(offset) });
} catch (e) {
res.status(500).json({ error: 'Failed to get devices', detail: e.message });
}
});
router.get('/api/device/:tag', requireAdmin, async (req, res) => {
try {
const data = await customerDevice.getCustomerDeviceData(req.params.tag);
if (!data || data.status === 'Tidak ditemukan') return res.status(404).json({ error: 'Device not found' });
res.json(data);
} catch (e) {
res.status(500).json({ error: 'Failed to get device details' });
}
});
router.post('/api/device/:tag/ssid', requireAdmin, express.json(), async (req, res) => {
const { ssid } = req.body;
if (!ssid) return res.status(400).json({ error: 'SSID required' });
const ok = await customerDevice.updateSSID(req.params.tag, ssid);
// Kirim notifikasi WhatsApp ke pelanggan
if (ok) {
try {
const tag = req.params.tag;
const cust = customerSvc.findCustomerByAny(tag);
if (cust && cust.phone) {
const now = getNowLocal();
const msg = `📶 *PERUBAHAN SSID WIFI*\n\n` +
`👤 *Pelanggan:* ${cust.name}\n` +
`🕒 *Waktu:* ${now}\n\n` +
`SSID WiFi Anda sudah diperbarui menjadi:\n` +
`📡 *${ssid}*\n\n` +
`Silakan pilih SSID baru di perangkat Anda untuk terhubung.\n` +
`⚠️ Jangan bagikan info ini ke orang lain.`;
await trySendWhatsappPayment(cust.phone, msg);
}
} catch (e) { logger.error('[Admin] Gagal kirim notif SSID via WA: ' + (e.message || e)); }
}
res.json({ success: ok });
});
router.post('/api/device/:tag/password', requireAdmin, express.json(), async (req, res) => {
const { password } = req.body;
if (!password || password.length < 8) return res.status(400).json({ error: 'Password minimal 8 karakter' });
const ok = await customerDevice.updatePassword(req.params.tag, password);
// Kirim notifikasi WhatsApp ke pelanggan
if (ok) {
try {
const tag = req.params.tag;
const cust = customerSvc.findCustomerByAny(tag);
if (cust && cust.phone) {
const now = getNowLocal();
const msg = `🔑 *PERUBAHAN PASSWORD WIFI*\n\n` +
`👤 *Pelanggan:* ${cust.name}\n` +
`🕒 *Waktu:* ${now}\n\n` +
`Password WiFi Anda sudah diperbarui menjadi:\n` +
`🔐 *${password}*\n\n` +
`Silakan gunakan password baru untuk terhubung.\n` +
`⚠️ Jangan bagikan password ini ke orang lain.`;
await trySendWhatsappPayment(cust.phone, msg);
}
} catch (e) { logger.error('[Admin] Gagal kirim notif Password via WA: ' + (e.message || e)); }
}
res.json({ success: ok });
});
router.post('/api/device/:tag/reboot', requireAdmin, async (req, res) => {
const result = await customerDevice.requestReboot(req.params.tag);
res.json(result);
});
router.post('/api/bulk/ssid', requireAdmin, express.json(), async (req, res) => {
const { tags, ssid } = req.body;
if (!Array.isArray(tags) || !ssid) return res.status(400).json({ error: 'Tags and SSID required' });
const results = [];
for (const tag of tags) {
try {
const success = await customerDevice.updateSSID(tag, ssid);
results.push({ tag, success });
// Kirim notifikasi WhatsApp ke pelanggan
if (success) {
try {
const cust = customerSvc.findCustomerByAny(tag);
if (cust && cust.phone) {
const now = getNowLocal();
const msg = `📶 *PERUBAHAN SSID WIFI*\n\n` +
`👤 *Pelanggan:* ${cust.name}\n` +
`🕒 *Waktu:* ${now}\n\n` +
`SSID WiFi Anda sudah diperbarui menjadi:\n` +
`📡 *${ssid}*\n\n` +
`Silakan pilih SSID baru di perangkat Anda untuk terhubung.\n` +
`⚠️ Jangan bagikan info ini ke orang lain.`;
await trySendWhatsappPayment(cust.phone, msg);
}
} catch (e) { /* ignore per-customer WA notification errors */ }
}
}
catch (e) { results.push({ tag, success: false, error: e.message }); }
}
res.json({ results, total: tags.length, success: results.filter(r => r.success).length });
});
router.get('/api/mikrotik/queue-types', requireAdmin, async (req, res) => {
try { res.json(await mikrotikService.getQueueTypes(req.query.routerId || null)); } catch (e) { res.json([]); }
});
router.get('/api/mikrotik/queue-parents', requireAdmin, async (req, res) => {
try { res.json(await mikrotikService.getQueueParents(req.query.routerId || null)); } catch (e) { res.json([]); }
});
router.get('/api/mikrotik/profiles', requireAdmin, async (req, res) => {
try {
const profiles = await mikrotikService.getPppoeProfiles(req.query.routerId);
res.json(profiles);
} catch (e) {
if (/not configured|belum dikonfigurasi|settings not/i.test(String(e.message || ''))) return res.json([]);
if (/not configured|belum dikonfigurasi|settings not/i.test(String(e.message || ''))) return res.json([]);
if (/not configured|belum dikonfigurasi|settings not/i.test(String(e.message || ''))) return res.json([]);
res.status(500).json({ error: e.message });
}
});
router.get('/api/mikrotik/users', requireAdmin, async (req, res) => {
try {
const routerId = req.query.routerId ? Number(req.query.routerId) : null;
const onlyUnused = String(req.query.onlyUnused || '') === '1';
const excludeCustomerId = req.query.excludeCustomerId ? Number(req.query.excludeCustomerId) : null;
const users = await mikrotikService.getPppoeUsers(routerId);
if (!onlyUnused) return res.json(users);
const rows = excludeCustomerId
? db.prepare("SELECT pppoe_username FROM customers WHERE router_id IS ? AND id != ? AND pppoe_username IS NOT NULL AND TRIM(pppoe_username) != ''").all(routerId, excludeCustomerId)
: db.prepare("SELECT pppoe_username FROM customers WHERE router_id IS ? AND pppoe_username IS NOT NULL AND TRIM(pppoe_username) != ''").all(routerId);
const used = new Set(rows.map(r => String(r.pppoe_username).trim()).filter(Boolean));
const filtered = (Array.isArray(users) ? users : []).filter(u => u && u.name && !used.has(String(u.name).trim()));
res.json(filtered);
} catch (e) {
if (/not configured|belum dikonfigurasi|settings not/i.test(String(e.message || ''))) return res.json([]);
res.status(500).json({ error: e.message });
}
});
// ─── MIKROTIK MONITORING ───────────────────────────────────────────────────
router.get('/mikrotik', requireAdminSession, requireSidebarMenuAccess('mikrotik'), (req, res) => {
const dbRouters = mikrotikService.getAllRouters();
const settings = getSettings();
const settingsRouter = {
id: '',
name: settings.mikrotik_host ? `MikroTik (settings.json - ${settings.mikrotik_host})` : 'MikroTik (settings.json)',
host: settings.mikrotik_host || '',
user: settings.mikrotik_user || '',
port: settings.mikrotik_port || 8728,
is_active: true
};
const routers = dbRouters.length > 0 ? dbRouters : [settingsRouter];
const activeRouterId = req.selectedRouterId || (routers[0] ? routers[0].id : '');
res.render('admin/mikrotik', {
title: 'Monitoring MikroTik', company: company(), activePage: 'mikrotik',
routers, activeRouterId, selectedRouterId: req.selectedRouterId, msg: flashMsg(req)
});
});
router.get('/mikrotik/display', requireAdminSession, (req, res) => {
const routers = mikrotikService.getAllRouters();
const settings = getSettings();
const defaultRouter = {
id: 'settings_json',
name: 'MikroTik (settings.json)',
host: settings.mikrotik_host || '',
user: settings.mikrotik_user || '',
port: settings.mikrotik_port || 8728,
is_active: true
};
const allRouters = (Array.isArray(routers) && routers.length) ? [...routers, defaultRouter] : [defaultRouter];
res.render('admin/mikrotik_display', {
title: 'MikroTik NOC Display',
company: company(),
routers: allRouters,
msg: flashMsg(req),
settings
});
});
router.get('/vouchers', requireAdminSession, (req, res) => {
const routers = mikrotikService.getAllRouters();
const selectedRouterId = req.selectedRouterId || (req.query.router_id ? Number(req.query.router_id) : null);
res.render('admin/vouchers', {
title: 'Manajemen Voucher', company: company(), activePage: 'mikrotik',
routers, selectedRouterId, msg: flashMsg(req), settings: getSettings()
});
});
router.get('/api/vouchers/template', requireAdminSession, (req, res) => {
const settings = getSettings();
res.json({
use_template: !!settings.voucher_print_use_template,
default_style: String(settings.voucher_print_default_style || ''),
header: String(settings.voucher_print_template_header || ''),
row: String(settings.voucher_print_template_row || ''),
footer: String(settings.voucher_print_template_footer || '')
});
});
router.post('/api/vouchers/template', requireAdminSession, restrictToAdmin, express.json({ limit: '1mb' }), (req, res) => {
try {
const useTemplate = !!req.body.use_template;
const defaultStyle = String(req.body.default_style || '').trim().toLowerCase();
const header = String(req.body.header || '');
const row = String(req.body.row || '');
const footer = String(req.body.footer || '');
saveSettings({
voucher_print_use_template: useTemplate,
voucher_print_default_style: defaultStyle,
voucher_print_template_header: header,
voucher_print_template_row: row,
voucher_print_template_footer: footer
});
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/api/webhook/payment-notif/logs', requireAdminSession, (req, res) => {
try {
const limit = Math.max(1, Math.min(500, Number(req.query.limit) || 200));
const service = String(req.query.service || '').trim();
const q = String(req.query.q || '').trim();
const where = [];
const params = [];
if (service) {
where.push('service = ?');
params.push(service);
}
if (q) {
where.push('(content LIKE ? OR service LIKE ?)');
params.push(`%${q}%`, `%${q}%`);
}
const sql = `
SELECT id, created_at, service, content, parsed_amount, parsed_ok, matched_invoice_id, matched_voucher_order_id, matched_donation_order_id, ip
FROM webhook_payment_notifs
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY id DESC
LIMIT ?
`;
const rows = db.prepare(sql).all(...params, limit);
// Convert created_at to local timezone
const rowsWithLocalTime = rows.map(row => ({
...row,
created_at: row.created_at ? formatDateLocal(row.created_at, 'YYYY-MM-DD HH:mm:ss') : null
}));
res.json({ ok: true, rows: rowsWithLocalTime });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.post('/api/webhook/payment-notif/clear', requireAdminSession, restrictToAdmin, express.json(), (req, res) => {
try {
db.prepare('DELETE FROM webhook_payment_notifs').run();
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.get('/vouchers/batches/:id/print', requireAdminSession, (req, res) => {
const batchId = Number(req.params.id);
const batch = db.prepare(`
SELECT b.*, r.name AS router_name
FROM voucher_batches b
LEFT JOIN routers r ON r.id = b.router_id
WHERE b.id = ?
`).get(batchId);
if (!batch) return res.status(404).send('Batch tidak ditemukan');
const vouchers = db.prepare(`
SELECT code, password, profile_name, used_at
FROM vouchers
WHERE batch_id = ?
ORDER BY code ASC
`).all(batchId);
const settings = getSettings();
const requestedStyle = String(req.query.style || '').trim().toLowerCase();
const style = requestedStyle || String(settings.voucher_print_default_style || '').trim().toLowerCase() || (settings.voucher_print_use_template ? 'template' : 'cards');
const escapeHtml = (s) => String(s ?? '').replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const stripUnsafe = (html) => {
let out = String(html || '');
out = out.replace(/<\?(?:php)?[\s\S]*?\?>/gi, '');
out = out.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
out = out.replace(/\son\w+\s*=\s*(['"]).*?\1/gi, '');
return out;
};
const applyVars = (tpl, vars) => {
let out = String(tpl || '');
out = out.replace(/%([a-zA-Z0-9_#]+)%/g, (m, k) => (vars[k] != null ? vars[k] : m));
out = out.replace(/\{\{\s*([a-zA-Z0-9_#]+)\s*\}\}/g, (m, k) => (vars[k] != null ? vars[k] : m));
return out;
};
const formatValidity = (v) => {
if (!v) return '-';
const s = String(v).trim();
const mDay = s.match(/^(\d+)\s*d$/i);
if (mDay) return `${Number(mDay[1])} hari`;
return s;
};
let renderedHtml = '';
let templateError = '';
const builtinTemplate = (name) => {
const phone = (Array.isArray(settings.whatsapp_admin_numbers) && settings.whatsapp_admin_numbers.length > 0)
? ('+' + String(settings.whatsapp_admin_numbers[0]))
: String(settings.company_phone || '');
const companyName = settings.company_header || company();
const timeStamp = new Date().toISOString();
const priceNumber = Number(batch.price || 0);
const priceText = priceNumber.toLocaleString('id-ID');
const validityText = formatValidity(batch.validity);
const rows = (vouchers || []).map((v, i) => {
const credential = (String(v.code) === String(v.password))
? escapeHtml(String(v.code))
: `U: ${escapeHtml(v.code)}<br>P: ${escapeHtml(v.password)}`;
return {
idx: i + 1,
username: escapeHtml(v.code),
password: escapeHtml(v.password),
credential,
profile: escapeHtml(batch.profile_name || v.profile_name || ''),
company: escapeHtml(companyName),
phone: escapeHtml(phone),
timeStamp: escapeHtml(timeStamp),
currency: 'Rp',
price: escapeHtml(String(priceNumber)),
priceText: escapeHtml(priceText),
validity: escapeHtml(batch.validity || ''),
validityText: escapeHtml(validityText),
};
});
if (name === 'mks') {
const css = `<style>
@page{size:A4;margin:6mm}
*{box-sizing:border-box}
body{margin:0;font-family:Arial,sans-serif;color:#0f172a}
.v-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.vc{border:1px solid #0f172a;border-radius:10px;min-height:110px;padding:8px;position:relative;break-inside:avoid;overflow:hidden}
.vc:before{content:"";position:absolute;inset:0;background:linear-gradient(120deg,rgba(59,130,246,.03),rgba(16,185,129,.025))}
.vc>*{position:relative}
.vh{font-weight:900;font-size:11px;letter-spacing:.2px}
.vp{position:absolute;top:8px;right:8px;font-size:10.5px;font-weight:800;background:rgba(16,185,129,.16);border:1px solid rgba(16,185,129,.35);padding:2px 7px;border-radius:999px}
.vm{font-size:10px;color:#334155;margin-top:6px}
.vu{font-weight:950;font-size:18px;letter-spacing:1px;margin-top:8px;font-family:Consolas,monospace;line-height:1.15}
.vf{position:absolute;left:50%;bottom:6px;transform:translateX(-50%);font-size:9.5px;color:#334155;white-space:nowrap}
</style>`;
const html = rows.map(r => `<div class="vc">
<div class="vh">${r.company}</div>
<div class="vp">${r.currency} ${r.priceText}</div>
<div class="vm">${r.profile} • ${r.validityText}</div>
<div class="vu">${r.credential}</div>
<div class="vf">WA: ${r.phone}</div>
</div>`).join('\n');
return `${css}<div class="v-grid">\n${html}\n</div>`;
}
if (name === 'simple') {
const css = `<style>
@page{size:A4;margin:6mm}
*{box-sizing:border-box}
body{margin:0;font-family:Arial,sans-serif;color:#0f172a}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.card{border:1.5px solid #334155;border-radius:10px;padding:8px 8px 22px;min-height:110px;position:relative;break-inside:avoid}
.hd{font-weight:800;font-size:11px}
.code{font-weight:900;font-size:22px;letter-spacing:2px;margin-top:6px;font-family:Consolas,monospace}
.meta{font-size:10px;color:#334155;margin-top:4px}
.wa{position:absolute;left:50%;bottom:6px;transform:translateX(-50%);font-size:9.5px;color:#334155;white-space:nowrap}
</style>`;
const html = rows.map(r => `<div class="card">
<div class="hd">${r.company}</div>
<div class="meta">${r.profile} • ${r.validityText} • ${r.currency} ${r.priceText}</div>
<div class="code">${r.username}</div>
<div class="meta">${r.password}</div>
<div class="wa">WA: ${r.phone}</div>
</div>`).join('\n');
return `${css}<div class="grid">\n${html}\n</div>`;
}
if (name === 'minimal') {
const css = `<style>
@page{size:A4;margin:6mm}
*{box-sizing:border-box}
body{margin:0;font-family:Arial,sans-serif;color:#0f172a}
.g{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.c{border:1px dashed #334155;border-radius:8px;padding:6px 6px 18px;min-height:84px;position:relative;break-inside:avoid}
.t{font-weight:900;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.m{font-size:9.5px;color:#334155;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.k{font-weight:950;font-size:18px;letter-spacing:2px;margin-top:8px;font-family:Consolas,monospace}
.w{position:absolute;left:6px;right:6px;bottom:5px;font-size:9px;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
</style>`;
const html = rows.map(r => `<div class="c">
<div class="t">${r.company}</div>
<div class="m">${r.profile}</div>
<div class="k">${r.username}</div>
<div class="w">${r.validityText} • ${r.currency} ${r.priceText} • ${r.phone}</div>
</div>`).join('\n');
return `${css}<div class="g">\n${html}\n</div>`;
}
return '';
};
if (style === 'mks' || style === 'simple' || style === 'minimal') {
renderedHtml = builtinTemplate(style);
} else if (style === 'template') {
const headerTpl = String(settings.voucher_print_template_header || '');
const rowTpl = String(settings.voucher_print_template_row || '');
const footerTpl = String(settings.voucher_print_template_footer || '');
if (rowTpl.trim()) {
const looksLikePhpOnly = (tpl) => {
const s = String(tpl || '');
const hasHtml = /<\s*[a-zA-Z][^>]*>/.test(s);
const phpSignals = /(\$[a-zA-Z_])|(\bif\s*\()|(\bsubstr\s*\()|(\bstrlen\s*\()|(\belse(if)?\b)|(\bforeach\b)/.test(s);
const manyPhp = (s.match(/\$/g) || []).length >= 3;
return !hasHtml && (phpSignals || manyPhp);
};
const combined = `${headerTpl}\n${rowTpl}\n${footerTpl}`;
if (/<\?(?:php)?/i.test(combined) || looksLikePhpOnly(combined)) {
templateError = 'Template yang dipaste masih format PHP (Mikhmon). Di sini hanya mendukung template HTML + placeholder (%username% dll).';
}
const phone = (Array.isArray(settings.whatsapp_admin_numbers) && settings.whatsapp_admin_numbers.length > 0)
? ('+' + String(settings.whatsapp_admin_numbers[0]))
: String(settings.company_phone || '');
const timeStamp = new Date().toISOString();
const priceNumber = Number(batch.price || 0);
const priceText = priceNumber.toLocaleString('id-ID');
const validityText = formatValidity(batch.validity);
const parts = [];
parts.push(stripUnsafe(applyVars(headerTpl, {
company: escapeHtml(settings.company_header || company()),
phone: escapeHtml(phone),
timeStamp: escapeHtml(timeStamp),
currency: 'Rp',
validityText: escapeHtml(validityText),
priceText: escapeHtml(priceText)
})));
vouchers.forEach((v, i) => {
const credential = (String(v.code) === String(v.password))
? escapeHtml(String(v.code))
: `U: ${escapeHtml(v.code)}<br>P: ${escapeHtml(v.password)}`;
const vars = {
username: escapeHtml(v.code),
password: escapeHtml(v.password),
profile: escapeHtml(batch.profile_name || v.profile_name || ''),
validity: escapeHtml(batch.validity || ''),
validityText: escapeHtml(validityText),
price: escapeHtml(String(priceNumber)),
priceText: escapeHtml(priceText),
currency: 'Rp',
company: escapeHtml(settings.company_header || company()),
phone: escapeHtml(phone),
timeStamp: escapeHtml(timeStamp),
'#': escapeHtml(String(i + 1)),
credential
};
parts.push(stripUnsafe(applyVars(rowTpl, vars)));
});
parts.push(stripUnsafe(applyVars(footerTpl, {
company: escapeHtml(settings.company_header || company()),
phone: escapeHtml(phone),
timeStamp: escapeHtml(timeStamp),
currency: 'Rp',
validityText: escapeHtml(validityText),
priceText: escapeHtml(priceText)
})));
renderedHtml = parts.join('\n');
}
}
let finalStyle = style;
if (finalStyle === 'template') {
const s = String(renderedHtml || '').trim();
if (!s || !/<\s*[a-zA-Z][^>]*>/.test(s) || templateError) {
renderedHtml = '';
finalStyle = 'cards';
}
}
res.render('admin/print_vouchers', {
title: 'Cetak Voucher',
company: company(),
settings,
batch,
vouchers,
style: finalStyle,
renderedHtml,
templateError
});
});
router.get('/vouchers/batches/:id/pdf', requireAdminSession, async (req, res) => {
try {
const batchId = Number(req.params.id);
const batch = db.prepare(`SELECT b.*, r.name AS router_name FROM voucher_batches b LEFT JOIN routers r ON r.id = b.router_id WHERE b.id = ?`).get(batchId);
if (!batch) return res.status(404).send('Batch tidak ditemukan');
const vouchers = db.prepare('SELECT code, password, profile_name, used_at FROM vouchers WHERE batch_id = ? ORDER BY code ASC').all(batchId);
const pdf = await require('../services/voucherPdfService').generateVoucherBatchPdfBuffer(batch, vouchers, getSettings());
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="Voucher_Batch_' + batchId + '.pdf"');
res.send(pdf);
} catch (e) { res.status(500).send('Gagal membuat PDF: ' + e.message); }
});
router.get('/vouchers/batches/:id/export.csv', requireAdminSession, (req, res) => {
const batchId = Number(req.params.id);
const batch = db.prepare(`
SELECT b.*, r.name AS router_name
FROM voucher_batches b
LEFT JOIN routers r ON r.id = b.router_id
WHERE b.id = ?
`).get(batchId);
if (!batch) return res.status(404).send('Batch tidak ditemukan');
const vouchers = db.prepare(`
SELECT code, password, profile_name, used_at
FROM vouchers
WHERE batch_id = ?
ORDER BY code ASC
`).all(batchId);
const lines = [];
lines.push(['code', 'password', 'profile', 'validity', 'price', 'router', 'batch_id', 'created_at', 'used_at'].join(','));
const createdAt = batch.created_at || '';
const validity = batch.validity || '';
const price = Number(batch.price || 0);
const routerName = batch.router_name || '';
for (const v of vouchers) {
const row = [
v.code,
v.password,
v.profile_name,
validity,
price,
routerName,
batchId,
createdAt,
v.used_at || ''
].map(x => `"${String(x ?? '').replace(/"/g, '""')}"`).join(',');
lines.push(row);
}
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=vouchers_batch_${batchId}.csv`);
res.send(lines.join('\n'));
});
router.get('/api/vouchers/batches', requireAdmin, (req, res) => {
const routerId = req.query.routerId ? Number(req.query.routerId) : null;
const rows = db.prepare(`
SELECT
b.*,
r.name AS router_name,
(SELECT COUNT(1) FROM vouchers v WHERE v.batch_id = b.id) AS vouchers_count,
(SELECT COUNT(1) FROM vouchers v WHERE v.batch_id = b.id AND v.used_at IS NOT NULL) AS used_count
FROM voucher_batches b
LEFT JOIN routers r ON r.id = b.router_id
WHERE (? IS NULL OR b.router_id = ?)
ORDER BY b.id DESC
LIMIT 200
`).all(routerId, routerId);
res.json(rows);
});
router.get('/api/vouchers/batches/:id', requireAdmin, (req, res) => {
const batchId = Number(req.params.id);
const batch = db.prepare(`
SELECT
b.*,
r.name AS router_name,
(SELECT COUNT(1) FROM vouchers v WHERE v.batch_id = b.id) AS vouchers_count,
(SELECT COUNT(1) FROM vouchers v WHERE v.batch_id = b.id AND v.used_at IS NOT NULL) AS used_count
FROM voucher_batches b
LEFT JOIN routers r ON r.id = b.router_id
WHERE b.id = ?
`).get(batchId);
if (!batch) return res.status(404).json({ error: 'Batch tidak ditemukan' });
const vouchers = db.prepare(`
SELECT id, code, password, profile_name, status, used_at, last_seen_comment, last_seen_uptime, last_seen_at
FROM vouchers
WHERE batch_id = ?
ORDER BY code ASC
LIMIT 2000
`).all(batchId);
res.json({ batch, vouchers });
});
router.post('/api/vouchers/batches', requireAdmin, express.json(), async (req, res) => {
try {
const routerId = req.query.routerId ? Number(req.query.routerId) : null;
const profileName = String(req.body.profile || '').trim();
const qty = Math.max(1, Math.min(5000, 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 priceInput = req.body.price;
if (!profileName) return res.status(400).json({ error: 'Profile wajib diisi' });
if (!qty) return res.status(400).json({ error: 'Jumlah voucher wajib diisi' });
if (prefix.length >= codeLength) return res.status(400).json({ error: 'Prefix terlalu panjang' });
const profiles = await mikrotikService.getHotspotUserProfiles(routerId);
const profile = profiles.find(p => p.name === profileName);
if (!profile) return res.status(400).json({ error: 'Profile Hotspot tidak ditemukan di MikroTik' });
const meta = parseMikhmonOnLogin(profile.onLogin || profile['on-login']);
if (!meta || !meta.validity) return res.status(400).json({ error: 'Profile belum memiliki metadata harga/durasi (Format Mikhmon)' });
const createdBy = req.session?.isAdmin ? (req.session.adminUser || 'admin') : (req.session.cashierName || 'kasir');
let price = Number(meta.price || 0);
if (priceInput !== undefined && priceInput !== null && String(priceInput).trim() !== '') {
const p = Number(priceInput);
if (!Number.isFinite(p) || p < 0) return res.status(400).json({ error: 'Harga tidak valid' });
price = Math.floor(p);
}
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-${createdBy}-${dd}.${mm}.${yy}`;
const comment = String(req.body.comment || defaultComment).trim();
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)
VALUES (?, ?, ?, 0, 0, ?, ?, ?, ?, 'creating', ?, ?, ?)
`);
const batchRes = insertBatch.run(routerId, profileName, qty, price, meta.validity || '', prefix, codeLength, createdBy, mode, charset);
const batchId = Number(batchRes.lastInsertRowid);
const insertVoucher = db.prepare(`
INSERT INTO vouchers (batch_id, router_id, code, password, profile_name, comment, status)
VALUES (?, ?, ?, ?, ?, ?, 'pending')
`);
const exists = db.prepare('SELECT 1 FROM vouchers WHERE router_id IS ? AND code = ? LIMIT 1');
const codes = new Set();
const makeCode = () => {
const coreLen = Math.max(4, Math.min(16, codeLength - prefix.length));
const userCode = prefix + genCode(coreLen, charset);
let passCode = userCode;
if (mode === 'member') {
passCode = genCode(coreLen, charset);
}
return { userCode, passCode };
};
const initialVouchers = [];
while (initialVouchers.length < qty) {
const generated = makeCode();
if (codes.has(generated.userCode)) continue;
if (exists.get(routerId, generated.userCode)) continue;
codes.add(generated.userCode);
initialVouchers.push(generated);
}
const tx = db.transaction((items) => {
for (const c of items) {
insertVoucher.run(batchId, routerId, c.userCode, c.passCode, profileName, comment);
}
});
tx(initialVouchers);
setImmediate(() => {
createVoucherBatchAsync(batchId).catch(e => logger.error('[VoucherBatch] Error: ' + (e?.message || e)));
});
res.json({ success: true, batchId });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/api/vouchers/batches/:id/sync', requireAdmin, 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({ error: 'Batch tidak ditemukan' });
const routerId = batch.router_id ?? null;
const users = await mikrotikService.getHotspotUsers(routerId);
const byName = new Map();
for (const u of users) {
if (u?.name) byName.set(String(u.name), u);
}
const list = db.prepare('SELECT id, code, used_at FROM vouchers WHERE batch_id = ?').all(batchId);
const updSeen = db.prepare("UPDATE vouchers SET last_seen_comment=?, last_seen_uptime=?, last_seen_at=(NOW_LOCAL()) WHERE id=?");
const markUsed = db.prepare("UPDATE vouchers SET used_at=(NOW_LOCAL()), status='used', last_seen_comment=?, last_seen_uptime=?, last_seen_at=(NOW_LOCAL()) WHERE id=?");
const markMissing = db.prepare("UPDATE vouchers SET status='missing', last_seen_at=(NOW_LOCAL()) WHERE id=?");
let usedNew = 0;
let missing = 0;
const tx = db.transaction(() => {
for (const v of list) {
const u = byName.get(String(v.code));
if (!u) {
markMissing.run(v.id);
missing++;
continue;
}
const comment = String(u.comment || '');
const uptime = String(u.uptime || '');
const isUsedByComment = comment && !comment.toLowerCase().startsWith('vc') && !comment.toLowerCase().startsWith('up');
const isUsedByUptime = uptime && uptime !== '0s' && uptime !== '0' && uptime !== '00:00:00';
const usedNow = isUsedByComment || isUsedByUptime;
if (usedNow && !v.used_at) {
markUsed.run(comment, uptime, v.id);
usedNew++;
} else {
updSeen.run(comment, uptime, v.id);
}
}
});
tx();
res.json({ success: true, usedNew, missing, total: list.length });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/api/vouchers/batches/:id/delete', requireAdmin, async (req, res) => {
try {
const batchId = Number(req.params.id);
if (!batchId) return res.status(400).json({ error: 'Batch ID tidak valid' });
const batch = db.prepare('SELECT id, status FROM voucher_batches WHERE id = ?').get(batchId);
if (!batch) return res.status(404).json({ error: 'Batch tidak ditemukan' });
if (String(batch.status) === 'creating') {
return res.status(400).json({ error: 'Batch sedang diproses (creating). Silakan tunggu hingga selesai.' });
}
const stats = db.prepare(`
SELECT
(SELECT COUNT(1) FROM vouchers v WHERE v.batch_id = ?) AS total,
(SELECT COUNT(1) FROM vouchers v WHERE v.batch_id = ? AND v.used_at IS NOT NULL) AS used
`).get(batchId, batchId);
const del = db.prepare('DELETE FROM voucher_batches WHERE id = ?');
del.run(batchId);
res.json({ success: true, deletedBatchId: batchId, deletedVouchers: stats?.total || 0, usedCount: stats?.used || 0 });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/api/mikrotik/secrets', requireAdmin, async (req, res) => {
try { res.json(await mikrotikService.getPppoeSecrets(req.query.routerId)); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/api/mikrotik/secrets', requireAdmin, express.json(), async (req, res) => {
try { await mikrotikService.addPppoeSecret(req.body, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/api/mikrotik/secrets/:id/update', requireAdmin, express.json(), async (req, res) => {
try { await mikrotikService.updatePppoeSecret(req.params.id, req.body, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/api/mikrotik/secrets/:id/delete', requireAdmin, async (req, res) => {
try { await mikrotikService.deletePppoeSecret(req.params.id, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.get('/api/mikrotik/hotspot-users', requireAdmin, async (req, res) => {
try { res.json(await mikrotikService.getHotspotUsers(req.query.routerId)); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/api/mikrotik/hotspot-users', requireAdmin, express.json(), async (req, res) => {
try { await mikrotikService.addHotspotUser(req.body, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/api/mikrotik/hotspot-users/:id/update', requireAdmin, express.json(), async (req, res) => {
try { await mikrotikService.updateHotspotUser(req.params.id, req.body, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/api/mikrotik/hotspot-users/:id/delete', requireAdmin, async (req, res) => {
try { await mikrotikService.deleteHotspotUser(req.params.id, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.get('/api/mikrotik/monitoring-display-data', requireAdmin, async (req, res) => {
const routerId = req.query.routerId ? (req.query.routerId === 'null' || req.query.routerId === 'settings' || req.query.routerId === 'settings_json' ? 'settings_json' : Number(req.query.routerId)) : 'settings_json';
try {
const conn = await mikrotikService.getConnection(routerId);
if (!conn) {
return res.status(500).json({ error: 'Gagal terhubung ke router MikroTik.' });
}
// Parallel execution for performance
const [
resource,
interfaces,
activePppoe,
secrets,
activeHotspot,
hotspotUsers
] = await Promise.all([
// 1. Resources
mikrotikService.getSystemResource(routerId).catch(err => {
logger.error('[NOC Display] Error resource:', err.message);
return null;
}),
// 2. Interfaces
conn.client.menu('/interface').get().catch(err => {
logger.error('[NOC Display] Error interfaces:', err.message);
return [];
}),
// 3. Active PPPoE
mikrotikService.getPppoeActive(routerId).catch(err => {
logger.error('[NOC Display] Error active PPPoE:', err.message);
return [];
}),
// 4. PPPoE Secrets
mikrotikService.getPppoeSecrets(routerId).catch(err => {
logger.error('[NOC Display] Error secrets:', err.message);
return [];
}),
// 5. Active Hotspot
mikrotikService.getHotspotActive(routerId).catch(err => {
logger.error('[NOC Display] Error active Hotspot:', err.message);
return [];
}),
// 6. Hotspot Users
mikrotikService.getHotspotUsers(routerId).catch(err => {
logger.error('[NOC Display] Error hotspot users:', err.message);
return [];
})
]);
// Calculate PPPoE Offline
const activePppoeNames = new Set((activePppoe || []).map(s => String(s.name).trim()));
const offlinePppoe = (secrets || []).filter(s => {
const isOnline = activePppoeNames.has(String(s.name).trim());
const isDisabled = s.disabled === true || s.disabled === 'true';
return !isOnline && !isDisabled;
});
// Format resources
const resData = {
cpu: resource ? String(resource['cpu-load'] || resource.cpuLoad || resource['cpu'] || '0') : '0',
freeMemory: resource ? Number(resource['free-memory'] || resource.freeMemory) || 0 : 0,
totalMemory: resource ? Number(resource['total-memory'] || resource.totalMemory) || 0 : 0,
uptime: resource ? String(resource['uptime'] || '00:00:00') : '00:00:00',
boardName: resource ? String(resource['board-name'] || resource.boardName || 'MikroTik') : 'MikroTik',
version: resource ? String(resource['version'] || 'N/A') : 'N/A'
};
// Format interfaces (only return fields needed)
const formattedInterfaces = (interfaces || []).map(i => {
return {
name: i.name,
type: i.type,
running: i.running === true || i.running === 'true' || i.running === 'yes',
disabled: i.disabled === true || i.disabled === 'true' || i.disabled === 'yes',
bytesIn: Number(i['rx-byte'] || i['rx-bytes'] || i['bytes-in']) || 0,
bytesOut: Number(i['tx-byte'] || i['tx-bytes'] || i['bytes-out']) || 0
};
});
res.json({
ok: true,
resources: resData,
interfaces: formattedInterfaces,
pppoe: {
active: activePppoe ? activePppoe.length : 0,
offline: offlinePppoe ? offlinePppoe.length : 0,
total: secrets ? secrets.length : 0
},
hotspot: {
active: activeHotspot ? activeHotspot.length : 0,
total: hotspotUsers ? hotspotUsers.length : 0
},
timestamp: Date.now()
});
} catch (e) {
logger.error('[NOC Display API] Error:', e.message);
res.status(500).json({ error: e.message });
}
});
router.get('/api/mikrotik/hotspot-profiles', requireAdmin, async (req, res) => {
try { res.json(await mikrotikService.getHotspotProfiles(req.query.routerId)); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.get('/api/mikrotik/active-pppoe', requireAdmin, async (req, res) => {
try { res.json(await mikrotikService.getPppoeActive(req.query.routerId)); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.get('/api/mikrotik/active-hotspot', requireAdmin, async (req, res) => {
try { res.json(await mikrotikService.getHotspotActive(req.query.routerId)); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.get('/api/mikrotik/ip-pools', requireAdmin, async (req, res) => {
try { res.json(await mikrotikService.getIpPools(req.query.routerId)); } catch (e) { res.status(500).json({ error: e.message }); }
});
// PPPoE Profiles CRUD
router.post('/api/mikrotik/pppoe-profiles', requireAdmin, express.json(), async (req, res) => {
try { await mikrotikService.addPppoeProfile(req.body, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/api/mikrotik/pppoe-profiles/:id/update', requireAdmin, express.json(), async (req, res) => {
try { await mikrotikService.updatePppoeProfile(req.params.id, req.body, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/api/mikrotik/pppoe-profiles/:id/delete', requireAdmin, async (req, res) => {
try { await mikrotikService.deletePppoeProfile(req.params.id, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
// Hotspot User Profiles CRUD
router.get('/api/mikrotik/hotspot-user-profiles', requireAdmin, async (req, res) => {
try {
const rows = await mikrotikService.getHotspotUserProfiles(req.query.routerId);
res.json((Array.isArray(rows) ? rows : []).map((r) => ({ ...r, id: r.id || r['.id'] })));
} catch (e) {
if (/not configured|belum dikonfigurasi|settings not/i.test(String(e.message || ''))) return res.json([]);
res.status(500).json({ error: e.message });
}
});
router.get('/api/mikrotik/hotspot-user-profiles/:id', requireAdmin, async (req, res) => {
try {
const row = await mikrotikService.getHotspotUserProfileById(req.params.id, req.query.routerId);
if (!row) return res.status(404).json({ error: 'Profile tidak ditemukan' });
return res.json({ ok: true, row });
} catch (e) {
return res.status(500).json({ error: e.message });
}
});
router.post('/api/mikrotik/hotspot-user-profiles', requireAdmin, express.json(), async (req, res) => {
try { await mikrotikService.addHotspotUserProfile(req.body, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/api/mikrotik/hotspot-user-profiles/:id/update', requireAdmin, express.json(), async (req, res) => {
try { await mikrotikService.updateHotspotUserProfile(req.params.id, req.body, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/api/mikrotik/hotspot-user-profiles/:id/delete', requireAdmin, async (req, res) => {
try { await mikrotikService.deleteHotspotUserProfile(req.params.id, req.query.routerId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
router.get('/api/mikrotik/backup', requireAdmin, async (req, res) => {
try {
const backup = await mikrotikService.getBackup(req.query.routerId);
res.setHeader('Content-disposition', 'attachment; filename=mikrotik_backup_' + new Date().toISOString().slice(0,10) + '.rsc');
res.setHeader('Content-type', 'text/plain');
res.send(backup);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── WHATSAPP ──────────────────────────────────────────────────────────────
// Global Broadcast Tracker
global.broadcastStatus = {
active: false,
total: 0,
sent: 0,
failed: 0,
startTime: null,
paused: false,
stopped: false,
currentBatch: 0,
messagesPerHour: 0,
hourlyLimit: 100
};
// Helper: Random delay generator untuk smart rate limiting
function getRandomDelay(baseDelayMs, varianceMs = 3000) {
const minDelay = Math.max(baseDelayMs - varianceMs, 2000);
const maxDelay = baseDelayMs + varianceMs;
return Math.floor(Math.random() * (maxDelay - minDelay + 1)) + minDelay;
}
// Helper: Exponential backoff untuk error handling
function getBackoffDelay(attemptCount, baseDelayMs = 2000) {
const maxDelay = 30000;
const delay = Math.min(baseDelayMs * Math.pow(2, attemptCount), maxDelay);
return delay + Math.floor(Math.random() * 1000);
}
// Helper: Message variation untuk menghindari spam detection
function addMessageVariation(message, index) {
const variations = [
'',
'\n\n_',
'\n\n•',
'\n\n▪',
'\n\n▫'
];
const suffix = variations[index % variations.length];
return message + suffix;
}
// Helper: Cek apakah waktu aman untuk broadcast (hindari jam sibuk)
function isSafeTimeToBroadcast() {
const now = new Date();
const hour = now.getHours();
// Hindari jam 00:00 - 06:00 (jam malam) dan jam 18:00 - 21:00 (jam sibuk)
return hour >= 8 && hour <= 17;
}
// Helper: Hitung delay berdasarkan jam (lebih lama di jam sibuk)
function getTimeBasedDelay(baseDelayMs) {
const now = new Date();
const hour = now.getHours();
// Jam sibuk (18:00 - 21:00): delay 2x lebih lama
if (hour >= 18 && hour <= 21) {
return baseDelayMs * 2;
}
// Jam malam (00:00 - 06:00): delay 3x lebih lama
if (hour >= 0 && hour <= 6) {
return baseDelayMs * 3;
}
// Jam normal: delay normal
return baseDelayMs;
}
// Helper: Cek duplicate message untuk menghindari spam
function isDuplicateMessage(phone, message, messageHistory) {
const key = `${phone}_${message.substring(0, 50)}`;
const lastSent = messageHistory.get(key);
if (!lastSent) return false;
const timeDiff = Date.now() - lastSent;
return timeDiff < 3600000; // 1 jam
}
// Helper: Cek apakah error adalah permanent (tidak perlu retry)
function isPermanentError(errorMessage) {
const permanentErrorPatterns = [
/invalid.*number/i,
/number.*not.*found/i,
/phone.*not.*exist/i,
/blocked/i,
/banned/i,
/not.*registered/i,
/user.*not.*found/i,
/404/i,
/400/i
];
return permanentErrorPatterns.some(pattern => pattern.test(errorMessage));
}
// Helper: Cek apakah error adalah temporary (bisa retry)
function isTemporaryError(errorMessage) {
const temporaryErrorPatterns = [
/timeout/i,
/network/i,
/connection/i,
/rate.*limit/i,
/too.*many/i,
/429/i,
/500/i,
/502/i,
/503/i,
/504/i
];
return temporaryErrorPatterns.some(pattern => pattern.test(errorMessage));
}
// Global message history untuk duplicate detection
global.broadcastMessageHistory = new Map();
const waSvc = require('../services/whatsappService');
router.get('/whatsapp/gowa/qr', requireAdminSession, async (req, res) => {
try {
const gowa = require('../services/gowaWhatsappService');
const dev = await gowa.ensureDevice();
if (dev && dev !== getSetting('gowa_device_id', '')) saveSettings({ gowa_device_id: dev });
const r = await gowa.getLoginQr(dev);
if (!r.ok) return res.json({ ok: false, message: 'Gagal membuat QR (' + (r.code || 'pastikan GOWA berjalan') + ').' });
return res.json({ ok: true, image: '/admin/whatsapp/gowa/qr.png?v=' + Date.now(), device_id: r.device_id, duration: r.duration });
} catch (e) { return res.json({ ok: false, message: e.message }); }
});
router.get('/whatsapp/gowa/qr.png', requireAdminSession, async (req, res) => {
try {
const img = await require('../services/gowaWhatsappService').getLastQrImage();
if (!img) return res.status(404).end();
res.set('Content-Type', img.contentType);
res.set('Cache-Control', 'no-store');
return res.send(img.buffer);
} catch (e) { return res.status(500).end(); }
});
router.get('/whatsapp', requireAdminSession, requireSidebarMenuAccess('whatsapp'), async (req, res) => {
const waGatewayType = getSetting('wa_gateway_type', 'gowa');
const metaSettings = {
meta_phone_number_id: getSetting('meta_phone_number_id', ''),
meta_waba_id: getSetting('meta_waba_id', ''),
meta_access_token: getSetting('meta_access_token', ''),
meta_verify_token: getSetting('meta_verify_token', 'antigravity_meta_wa_secret'),
meta_business_phone: getSetting('meta_business_phone', '')
};
const fonnteSettings = {
fonnte_token: getSetting('fonnte_token', ''),
fonnte_api_url: getSetting('fonnte_api_url', 'https://api.fonnte.com/send'),
fonnte_country_code: getSetting('fonnte_country_code', '62')
};
const gowaSettings = {
gowa_base_url: getSetting('gowa_base_url', 'http://127.0.0.1:3000'),
gowa_username: getSetting('gowa_username', ''),
gowa_password: getSetting('gowa_password', ''),
gowa_device_id: getSetting('gowa_device_id', ''),
gowa_webhook_secret: getSetting('gowa_webhook_secret', 'secret')
};
const gowaWebhookUrl = String(getSetting('app_url', '') || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '') + '/api/webhook/gowa';
const gowaUiHost = String(req.get('host') || '').split(':')[0];
const gowaUiUrl = String(getSetting('gowa_base_url', '') || getSetting('gowa_ui_url', '') || (gowaUiHost ? ('https://gowa.' + gowaUiHost) : 'http://127.0.0.1:3000')).replace(/\/+$/, '');
res.render('admin/whatsapp', {
title: 'Status WhatsApp',
company: company(),
activePage: 'whatsapp',
msg: flashMsg(req),
waGatewayType,
metaSettings,
fonnteSettings,
gowaSettings,
gowaWebhookUrl,
gowaUiUrl,
host: req.get('host') || 'localhost:3001'
});
});
router.post('/whatsapp/gateway-settings', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const {
wa_gateway_type,
meta_phone_number_id,
meta_waba_id,
meta_access_token,
meta_verify_token,
meta_business_phone,
fonnte_token,
fonnte_api_url,
fonnte_country_code,
gowa_base_url,
gowa_username,
gowa_password,
gowa_device_id,
gowa_webhook_secret
} = req.body;
saveSettings({
wa_gateway_type: wa_gateway_type || 'baileys',
meta_phone_number_id: String(meta_phone_number_id || '').trim(),
meta_waba_id: String(meta_waba_id || '').trim(),
meta_access_token: String(meta_access_token || '').trim(),
meta_verify_token: String(meta_verify_token || '').trim() || 'antigravity_meta_wa_secret',
meta_business_phone: String(meta_business_phone || '').trim(),
fonnte_token: String(fonnte_token || '').trim(),
fonnte_api_url: String(fonnte_api_url || 'https://api.fonnte.com/send').trim(),
fonnte_country_code: String(fonnte_country_code || '62').trim(),
gowa_base_url: String(gowa_base_url || 'http://127.0.0.1:3000').trim(),
gowa_username: String(gowa_username || '').trim(),
gowa_password: String(gowa_password || '').trim(),
gowa_device_id: String(gowa_device_id || '').trim(),
gowa_webhook_secret: String(gowa_webhook_secret || 'secret').trim()
});
if ((wa_gateway_type || 'baileys') === 'baileys') {
import('../services/whatsappBot.mjs').then(m => m.startWhatsAppBot()).catch(e => {
logger.error('Failed to start Baileys bot on gateway change: ' + e.message);
});
}
req.session._msg = { type: 'success', text: 'Pengaturan WhatsApp Gateway (Baileys / Fonnte / Meta) berhasil disimpan.' };
} catch (e) {
req.session._msg = { type: 'danger', text: 'Gagal menyimpan pengaturan: ' + e.message };
}
res.redirect('/admin/whatsapp');
});
router.post('/whatsapp/test-gowa', requireAdminSession, express.json(), async (req, res) => {
try {
const { phone, message } = req.body;
if (!phone) return res.status(400).json({ success: false, error: 'Nomor HP tujuan wajib diisi.' });
const gowaSvc = require('../services/gowaWhatsappService');
const result = await gowaSvc.testGowaConnection(phone, message);
res.json({ success: true, message: 'Pesan berhasil dikirim via GOWA Gateway!', data: result });
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
router.post('/whatsapp/test-fonnte', requireAdminSession, express.json(), async (req, res) => {
try {
const { phone, message } = req.body;
if (!phone) return res.status(400).json({ success: false, error: 'Nomor HP tujuan wajib diisi.' });
const fonnteSvc = require('../services/fonnteWhatsappService');
const result = await fonnteSvc.testFonnteConnection(phone, message);
res.json({ success: true, message: 'Pesan berhasil dikirim via Fonnte Gateway!', data: result });
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
// LIVE CHAT ROUTES
router.get('/whatsapp/live-chat', requireAdminSession, requireSidebarMenuAccess('whatsapp'), async (req, res) => {
const waGatewayType = getSetting('wa_gateway_type', 'gowa');
const customers = customerSvc.getAllCustomers();
res.render('admin/whatsapp_live_chat', {
title: 'Live Chat WhatsApp',
company: company(),
activePage: 'whatsapp',
msg: flashMsg(req),
gatewayType: waGatewayType,
customers: customers || []
});
});
router.get('/api/whatsapp/conversations', requireAdminSession, (req, res) => {
try {
const list = waSvc.getRecentConversations(50);
res.json({ ok: true, conversations: list });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.get('/api/whatsapp/messages', requireAdminSession, (req, res) => {
try {
const phone = req.query.phone || '';
const list = waSvc.getChatHistory(phone, 100);
res.json({ ok: true, messages: list });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.post('/api/whatsapp/send-direct', requireAdminSession, express.json(), async (req, res) => {
try {
const { phone, message } = req.body;
if (!phone || !message) return res.status(400).json({ success: false, error: 'Nomor dan pesan tidak boleh kosong' });
await waSvc.sendWhatsAppMessage(phone, message);
res.json({ success: true, message: 'Pesan WhatsApp terkirim' });
} catch (e) {
const errorText = e?.message || (typeof e === 'string' ? e : JSON.stringify(e));
res.status(500).json({ success: false, error: errorText });
}
});
router.get('/whatsapp/templates', requireAdminSession, requireSidebarMenuAccess('whatsapp'), async (req, res) => {
const comp = company();
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 ${comp}`;
const defaultQris = `Yth. Pelanggan {{nama}},\n\nBerikut rincian tagihan manual + Kode Bayar QRIS Anda:\n\n📦 *Paket:* {{paket}}\n📅 *Periode:* {{periode}}\n💰 *Nominal:* Rp {{qris_nominal}}\n\nSilakan scan QRIS berikut untuk melakukan pembayaran otomatis:\n{{qris_qr}}\n\nTerima kasih.`;
const defaultSuccess = whatsappService.DEFAULT_PAYMENT_SUCCESS_TEMPLATE;
const defaultIsolir = "{Halo|Salam} Bapak/Ibu *{{nama}}*, 🙏\n\nDengan hormat, layanan internet Anda untuk sementara kami nonaktifkan karena tagihan berikut belum kami terima:\n\n📋 *RINGKASAN TAGIHAN*\n• ID Pelanggan : *{{id_pelanggan}}*\n• Paket : {{paket}}\n• Total Tagihan : *Rp {{tagihan}}*\n\nKami memahami apabila ada kendala. Mohon berkenan menyelesaikan pembayaran agar layanan dapat kami aktifkan kembali:\n{{link}}\n\nApabila membutuhkan bantuan, silakan menghubungi kami. Terima kasih atas perhatian dan kerja samanya. 🙏\n— {{company}}";
const defaultAutolunas = '✅ *Tagihan LUNAS*\n\nYth. Bapak/Ibu *{{nama}}* (ID Pelanggan: {{id_pelanggan}}),\n\nTerima kasih, tagihan layanan internet Anda telah kami tandai *LUNAS* secara otomatis.\n\n💵 Nominal: *Rp {{tagihan}}*\n📅 Periode: {{rincian}}\n\nPesan ini kami kirim sebagai validasi & pencatatan. Terima kasih 🙏\n— {{company}}';
const defaultDeactivated = '⚠️ *Layanan Dinonaktifkan*\n\nYth. Bapak/Ibu *{{nama}}* (ID Pelanggan: {{id_pelanggan}}),\n\nLayanan internet Anda untuk sementara kami *nonaktifkan* karena tagihan belum diselesaikan selama *{{bulan_nunggak}} bulan berturut-turut*.\n\n💵 Total Tagihan: *Rp {{tagihan}}*\n\nApabila bulan depan masih belum dibayar, akun Anda akan *DIHAPUS OTOMATIS* dan untuk berlangganan kembali harus *mendaftar dari awal*.\n\nBayar: {{link}}\n— {{company}}';
const defaultDeleted = '🗑️ *Akun Dihapus*\n\nYth. *{{nama}}*,\n\nAkun layanan internet Anda telah *DIHAPUS OTOMATIS* karena tagihan tidak dibayar *{{bulan_nunggak}} bulan berturut-turut*.\n\nUntuk berlangganan kembali, silakan *mendaftar dari awal*. Terima kasih.\n— {{company}}';
const defaultArrears = "*Pemberitahuan Tagihan Internet*\n\nYth. Bapak/Ibu *{{nama}}* (ID Pelanggan: {{id_pelanggan}}),\n\nSemoga Bapak/Ibu selalu dalam keadaan sehat dan baik. 🙏\n\nDengan hormat, kami menginformasikan bahwa pembayaran tagihan layanan internet Anda belum kami terima selama *{{bulan_nunggak}} bulan* berjalan.\n\n📦 Paket: {{paket}}\n💰 Total Tagihan: Rp {{tagihan}}\n📅 Periode: {{rincian}}\n\nKami memahami apabila ada kendala. Mohon berkenan menyelesaikan pembayaran agar layanan tetap dapat digunakan dengan nyaman. Apabila hingga {{batas}} bulan tagihan belum diselesaikan, dengan berat hati layanan akan kami nonaktifkan sementara, dan untuk mengaktifkannya kembali diperlukan pendaftaran ulang.\n\nPembayaran dapat dilakukan melalui portal pelanggan:\n{{link}}\n\nApabila ada pertanyaan atau membutuhkan bantuan, silakan menghubungi kami. Terima kasih atas perhatian dan kerja samanya. 🙏\n— {{company}}";
const templates = {
whatsapp_auto_billing_message: db.getAppSetting('whatsapp_auto_billing_message', defaultAutoBilling),
whatsapp_billing_qris_message: db.getAppSetting('whatsapp_billing_qris_message', defaultQris),
whatsapp_payment_success_message: db.getAppSetting('whatsapp_payment_success_message', defaultSuccess),
whatsapp_isolir_message: db.getAppSetting('whatsapp_isolir_message', defaultIsolir),
whatsapp_arrears_warning_message: db.getAppSetting('whatsapp_arrears_warning_message', defaultArrears),
whatsapp_autolunas_message: db.getAppSetting('whatsapp_autolunas_message', defaultAutolunas),
whatsapp_deactivated_message: db.getAppSetting('whatsapp_deactivated_message', defaultDeactivated),
whatsapp_deleted_message: db.getAppSetting('whatsapp_deleted_message', defaultDeleted)
};
res.render('admin/whatsapp_templates', {
title: 'Template Pesan WhatsApp',
company: comp,
activePage: 'whatsapp',
msg: flashMsg(req),
templates
});
});
router.post('/whatsapp/templates', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const {
whatsapp_auto_billing_message,
whatsapp_billing_qris_message,
whatsapp_payment_success_message,
whatsapp_isolir_message,
whatsapp_arrears_warning_message,
whatsapp_autolunas_message,
whatsapp_deactivated_message,
whatsapp_deleted_message
} = req.body;
db.saveAppSetting('whatsapp_auto_billing_message', whatsapp_auto_billing_message || '');
db.saveAppSetting('whatsapp_billing_qris_message', whatsapp_billing_qris_message || '');
db.saveAppSetting('whatsapp_payment_success_message', whatsapp_payment_success_message || '');
db.saveAppSetting('whatsapp_isolir_message', whatsapp_isolir_message || '');
db.saveAppSetting('whatsapp_arrears_warning_message', whatsapp_arrears_warning_message || '');
db.saveAppSetting('whatsapp_autolunas_message', whatsapp_autolunas_message || '');
db.saveAppSetting('whatsapp_deactivated_message', whatsapp_deactivated_message || '');
db.saveAppSetting('whatsapp_deleted_message', whatsapp_deleted_message || '');
req.session._msg = { type: 'success', text: 'Template WhatsApp berhasil disimpan ke database.' };
} catch (e) {
req.session._msg = { type: 'danger', text: 'Gagal menyimpan template: ' + e.message };
}
res.redirect('/admin/whatsapp/templates');
});
function buildWaPreviewMessages() {
const comp = company();
const base = (getSetting('app_url', '') || getSetting('public_base_url', '') || 'http://localhost:3001').replace(/\/+$/, '');
const nowTxt = new Date().toLocaleString('id-ID', { timeZone: getSetting('timezone', 'Asia/Jakarta') });
const sample = { nama: 'Budi Santoso', id: 'SKY-12345', paket: 'Home 20 Mbps', tagihan: '250.000', periode: '10/2026', link: base + '/customer', otp: '482913', ssid: 'OPEN-ISP-BUDI', password: 'wifi-ku-2026', waktu: nowTxt, bulan: '3', batas: '3' };
const rv = (t) => String(t || '')
.replace(/{{nama}}/gi, sample.nama).replace(/{{id_pelanggan}}/gi, sample.id).replace(/{{paket}}/gi, sample.paket)
.replace(/{{tagihan}}/gi, sample.tagihan).replace(/{{total}}/gi, sample.tagihan).replace(/{{qris_nominal}}/gi, sample.tagihan)
.replace(/{{rincian}}/gi, sample.periode).replace(/{{periode}}/gi, sample.periode).replace(/{{bulan_nunggak}}/gi, sample.bulan).replace(/{{batas}}/gi, sample.batas)
.replace(/{{link}}/gi, sample.link).replace(/{{link_portal}}/gi, sample.link).replace(/{{link_pdf}}/gi, sample.link + '/invoice/1001/pdf')
.replace(/{{company}}/gi, comp).replace(/{{company_phone}}/gi, '-').replace(/{{no_invoice}}/gi, '1001')
.replace(/{{waktu}}/gi, sample.waktu).replace(/{{tanggal}}/gi, sample.waktu).replace(/{{metode}}/gi, 'QRIS')
.replace(/{{username}}/gi, 'budi_pppoe').replace(/{{pppoe}}/gi, 'budi_pppoe').replace(/{{otp}}/gi, sample.otp);
const g = (k, def) => db.getAppSetting(k, def) || def;
const messages = [
{ title: 'Pengingat Tagihan (otomatis H-N)', key: 'whatsapp_auto_billing_message', text: rv(g('whatsapp_auto_billing_message', 'Yth. {{nama}}, tagihan Rp {{tagihan}} periode {{rincian}}. Bayar: {{link}}')) },
{ title: 'Tagihan QRIS (dengan gambar QR)', key: 'whatsapp_billing_qris_message', text: rv(g('whatsapp_billing_qris_message', 'Yth. {{nama}}, tagihan Rp {{tagihan}} periode {{rincian}}. Scan QRIS terlampir.')) + '\n\n[gambar QRIS dinamis]' },
{ title: 'Pembayaran Sukses / LUNAS', key: 'whatsapp_payment_success_message', text: rv(g('whatsapp_payment_success_message', 'PEMBAYARAN LUNAS\n\nYth. {{nama}}, pembayaran tagihan periode {{periode}} sebesar Rp {{total}} telah kami terima.\n\nNo. Invoice: #INV-{{no_invoice}}\nMetode: {{metode}}\nWaktu: {{waktu}}\n\nInvoice PDF: {{link_pdf}}\nTerima kasih.')) },
{ title: 'Pemberitahuan Isolir', key: 'whatsapp_isolir_message', text: rv(g('whatsapp_isolir_message', 'Layanan internet Anda ditangguhkan karena tagihan Rp {{tagihan}} belum dibayar.')) },
{ title: 'Peringatan Tunggakan', key: 'whatsapp_arrears_warning_message', text: rv(g('whatsapp_arrears_warning_message', 'Tagihan Rp {{tagihan}} menunggak {{bulan_nunggak}} bulan.')) },
{ title: 'Auto-Lunas (nominal 0 / auto_isolate=0)', key: 'whatsapp_autolunas_message', text: rv(g('whatsapp_autolunas_message', 'Tagihan LUNAS otomatis.')) },
{ title: 'Auto-Nonaktif (menunggak N bulan)', key: 'whatsapp_deactivated_message', text: rv(g('whatsapp_deactivated_message', 'Layanan dinonaktifkan.')) },
{ title: 'Auto-Hapus (bulan ke-N)', key: 'whatsapp_deleted_message', text: rv(g('whatsapp_deleted_message', 'Akun dihapus.')) },
{ title: 'Kode OTP Login', key: '(pesan sistem)', text: 'Kode OTP Anda: *' + sample.otp + '*\n\nBerlaku 10 menit. Jangan bagikan kode ini ke siapa pun.', note: 'Pesan sistem (OTP) — dikirim saat login.' },
{ title: 'Perubahan Nama WiFi (SSID)', key: '(pesan sistem)', text: 'PERUBAHAN SSID WIFI\n\nPelanggan: ' + sample.nama + '\nWaktu: ' + sample.waktu + '\n\nSSID WiFi Anda sudah diperbarui menjadi:\n*' + sample.ssid + '*\n\nSilakan pilih SSID baru di perangkat Anda untuk terhubung.', note: 'Pesan sistem — saat pelanggan mengubah nama WiFi.' },
{ title: 'Perubahan Password WiFi', key: '(pesan sistem)', text: 'PERUBAHAN PASSWORD WIFI\n\nPelanggan: ' + sample.nama + '\nWaktu: ' + sample.waktu + '\n\nPassword WiFi Anda sudah diperbarui menjadi:\n*' + sample.password + '*\n\nSilakan gunakan password baru untuk terhubung.', note: 'Pesan sistem — saat pelanggan mengubah password WiFi.' }
];
return { messages, sample };
}
router.get('/whatsapp/preview', requireAdminSession, requireSidebarMenuAccess('whatsapp'), (req, res) => {
const { messages, sample } = buildWaPreviewMessages();
res.render('admin/whatsapp_preview', { title: 'Preview Pesan WhatsApp', company: company(), activePage: 'whatsapp', msg: flashMsg(req), messages, sample });
});
router.post('/whatsapp/preview/send', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const idx = parseInt(req.body.index, 10);
const { messages } = buildWaPreviewMessages();
const m = messages[idx];
if (!m) throw new Error('Pesan tidak ditemukan');
const waNotify = require('../services/waNotifyService');
const txt = '[CONTOH] ' + m.title + '\n\n' + m.text;
const r = await waNotify.notifyAdmin(txt);
return res.json({ ok: true, message: 'Contoh "' + m.title + '" terkirim ke WA admin (sent=' + ((r && r.sent) || 0) + ').' });
} catch (e) {
return res.status(500).json({ ok: false, message: e.message });
}
});
router.get('/whatsapp/broadcast', requireAdminSession, requireSidebarMenuAccess('whatsapp'), (req, res) => {
const comp = company();
const defaultAutoBillingMsg =
`Yth. Pelanggan {{nama}},\n\n` +
`Ini adalah pengingat sebelum tanggal jatuh tempo/isolir.\n\n` +
`📦 *Paket:* {{paket}}\n` +
`💰 *Total Tagihan:* Rp {{tagihan}}\n` +
`📅 *Periode:* {{rincian}}\n\n` +
`Mohon segera melakukan pembayaran melalui portal pelanggan: {{link}}\n\n` +
`Terima kasih atas kerja samanya.\n` +
`Salam,\nAdmin ${comp}`;
const autoBillingMsg = db.getAppSetting('whatsapp_auto_billing_message', defaultAutoBillingMsg);
const _bsvc = require('../services/billingSendService');
res.render('admin/broadcast', {
title: 'Broadcast WhatsApp', company: comp, activePage: 'whatsapp', msg: flashMsg(req),
broadcastStatus: global.broadcastStatus, getSetting, autoBillingMsg,
isolateDays: db.prepare("SELECT DISTINCT COALESCE(isolate_day,0) d FROM customers WHERE package_id IS NOT NULL AND COALESCE(isolate_day,0)>0 ORDER BY d").all().map(function (x) { return x.d; }),
billingSendStatus: _bsvc.getStatus(),
billingTemplates: _bsvc.TEMPLATE_LABELS
});
});
router.post('/whatsapp/send-billing-now', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const svc = require('../services/billingSendService');
const r = await svc.start({
isolateDay: req.body.isolate_day,
templateKey: req.body.template_key,
intervalMode: req.body.interval_mode,
intervalValue: req.body.interval_value,
intervalMin: req.body.interval_min,
intervalMax: req.body.interval_max,
});
req.session._msg = r.started
? { type: 'success', text: 'Pengiriman tagihan dimulai: ' + r.total + ' pelanggan (' + r.label + '). Pantau di halaman ini.' }
: { type: 'error', text: r.message || 'Tidak ada target.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/whatsapp/broadcast');
});
router.get('/api/whatsapp/billing-send-status', requireAdminSession, (req, res) => {
res.json(require('../services/billingSendService').getStatus());
});
router.post('/api/whatsapp/billing-send-stop', requireAdminSession, (req, res) => {
res.json({ ok: true, status: require('../services/billingSendService').stop() });
});
router.get('/api/whatsapp/broadcast-status', requireAdminSession, (req, res) => {
res.json(global.broadcastStatus);
});
// API: Pause Broadcast
router.post('/api/whatsapp/broadcast-pause', requireAdminSession, (req, res) => {
if (!global.broadcastStatus.active) {
return res.json({ ok: false, error: 'Tidak ada broadcast yang sedang berjalan.' });
}
global.broadcastStatus.paused = true;
logger.info('[Broadcast] Broadcast dipause oleh admin.');
res.json({ ok: true, message: 'Broadcast berhasil dipause.' });
});
// API: Resume Broadcast
router.post('/api/whatsapp/broadcast-resume', requireAdminSession, (req, res) => {
if (!global.broadcastStatus.active) {
return res.json({ ok: false, error: 'Tidak ada broadcast yang sedang berjalan.' });
}
global.broadcastStatus.paused = false;
logger.info('[Broadcast] Broadcast dilanjutkan oleh admin.');
res.json({ ok: true, message: 'Broadcast berhasil dilanjutkan.' });
});
// API: Stop Broadcast
router.post('/api/whatsapp/broadcast-stop', requireAdminSession, (req, res) => {
if (!global.broadcastStatus.active) {
return res.json({ ok: false, error: 'Tidak ada broadcast yang sedang berjalan.' });
}
global.broadcastStatus.stopped = true;
global.broadcastStatus.paused = false;
logger.info('[Broadcast] Broadcast dihentikan oleh admin.');
res.json({ ok: true, message: 'Broadcast berhasil dihentikan.' });
});
router.post('/whatsapp/broadcast', requireAdminSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const { target, message, delay: customDelay, batchSize: customBatchSize, hourlyLimit: customHourlyLimit } = req.body;
if (!message) throw new Error('Pesan tidak boleh kosong');
// Smart Rate Limit Settings
const baseDelayMs = (parseInt(customDelay) || getSetting('whatsapp_broadcast_delay', 5)) * 1000; // Default 5 detik
const batchSize = parseInt(customBatchSize) || 15; // Default 15 pesan per batch (lebih aman)
const batchPauseMs = 120000; // Pause 2 menit setelah setiap batch (lebih aman)
const hourlyLimit = parseInt(customHourlyLimit) || 80; // Default 80 pesan per jam (lebih aman)
if (customDelay) {
const v = parseInt(customDelay);
if (Number.isFinite(v) && v >= 1 && v <= 60) {
saveSettings({ whatsapp_broadcast_delay: v });
}
}
if (global.broadcastStatus.active) {
throw new Error('Ada proses broadcast yang sedang berjalan. Silakan tunggu hingga selesai.');
}
let customers = [];
const allCust = customerSvc.getAllCustomers();
if (target === 'all') {
customers = allCust;
} else if (target === 'active') {
customers = allCust.filter(c => c.status === 'active');
} else if (target === 'suspended') {
customers = allCust.filter(c => c.status === 'suspended');
} else if (target === 'unpaid') {
customers = allCust.filter(c => c.unpaid_count > 0);
}
// Ambil pelanggan unik berdasarkan nomor HP
const uniqueCustomers = [];
const seenPhones = new Set();
for (const c of customers) {
if (c.phone && c.phone.length > 8 && !seenPhones.has(c.phone)) {
uniqueCustomers.push(c);
seenPhones.add(c.phone);
}
}
if (uniqueCustomers.length === 0) {
throw new Error('Tidak ada nomor pelanggan yang valid untuk target tersebut.');
}
// Initialize Tracker dengan Smart Rate Limit
global.broadcastStatus = {
active: true,
total: uniqueCustomers.length,
sent: 0,
failed: 0,
startTime: new Date(),
paused: false,
stopped: false,
currentBatch: 0,
messagesPerHour: 0,
hourlyLimit: hourlyLimit
};
const sendMessageAsync = async () => {
let batchCount = 0;
let windowStartTime = Date.now();
let messagesInCurrentHour = 0;
for (let i = 0; i < uniqueCustomers.length; i++) {
if (global.broadcastStatus.stopped) {
logger.info('[Broadcast] Dihentikan oleh admin.');
break;
}
while (global.broadcastStatus.paused) {
await new Promise(r => setTimeout(r, 1000));
if (global.broadcastStatus.stopped) break;
}
if (global.broadcastStatus.stopped) break;
// Rate Limiting: Cek batas per jam
const now = Date.now();
if (now - windowStartTime >= 3600000) {
windowStartTime = now;
messagesInCurrentHour = 0;
global.broadcastStatus.messagesPerHour = 0;
}
if (messagesInCurrentHour >= hourlyLimit) {
const waitTimeMs = 3600000 - (now - windowStartTime);
logger.info(`[Broadcast] Batas per jam tercapai (${hourlyLimit} pesan). Menunggu ${Math.ceil(waitTimeMs / 60000)} menit...`);
await new Promise(r => setTimeout(r, waitTimeMs));
windowStartTime = Date.now();
messagesInCurrentHour = 0;
global.broadcastStatus.messagesPerHour = 0;
}
const cust = uniqueCustomers[i];
let attemptCount = 0;
const maxAttempts = 3;
while (attemptCount < maxAttempts) {
try {
// Smart Random Delay
const randomDelay = getRandomDelay(baseDelayMs, 2000);
await new Promise(r => setTimeout(r, randomDelay));
// Format Pesan dengan Spintax
let formattedMsg = message.replace(/{{nama}}/gi, cust.name || 'Pelanggan');
formattedMsg = formattedMsg.replace(/{{id_pelanggan}}/gi, cust.customer_code || '-');
formattedMsg = formattedMsg.replace(/{{username}}/gi, cust.pppoe_username || '-');
formattedMsg = formattedMsg.replace(/{{pppoe}}/gi, cust.pppoe_username || '-');
formattedMsg = formattedMsg.replace(/{{company}}/gi, company());
formattedMsg = formattedMsg.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-');
const { parseSpintax } = await import('../services/whatsappBot.mjs');
formattedMsg = parseSpintax(formattedMsg);
formattedMsg = addMessageVariation(formattedMsg, i);
// Jeda mengetik manusiawi sebelum kirim
await waSvc.humanTypingDelay(formattedMsg);
await waSvc.sendWhatsAppMessage(cust.phone, formattedMsg);
global.broadcastStatus.sent++;
messagesInCurrentHour++;
global.broadcastStatus.messagesPerHour = messagesInCurrentHour;
batchCount++;
// Batch Processing: Pause setelah N pesan
if (batchCount >= batchSize && i < uniqueCustomers.length - 1) {
logger.info(`[Broadcast] Selesai batch ${global.broadcastStatus.currentBatch + 1} (${batchSize} pesan). Pause ${Math.floor(batchPauseMs / 1000)} detik...`);
global.broadcastStatus.currentBatch++;
await new Promise(r => setTimeout(r, batchPauseMs));
batchCount = 0;
}
break; // Sukses, keluar dari retry loop
} catch (e) {
attemptCount++;
const errorMsg = e.message || e.toString();
// Cek apakah error permanent (tidak perlu retry)
if (isPermanentError(errorMsg)) {
logger.warn(`[Broadcast] SKIP: Error permanent untuk ${cust.phone} - ${errorMsg}`);
global.broadcastStatus.failed++;
break; // Skip retry langsung ke pelanggan berikutnya
}
// Error temporary, bisa retry
logger.error(`[Broadcast] Gagal kirim ke ${cust.phone} (attempt ${attemptCount}/${maxAttempts}): ${errorMsg}`);
if (attemptCount >= maxAttempts) {
logger.warn(`[Broadcast] Max attempts tercapai untuk ${cust.phone}`);
global.broadcastStatus.failed++;
} else {
// Exponential backoff untuk retry
const backoffDelay = getBackoffDelay(attemptCount);
logger.info(`[Broadcast] Retry ke ${cust.phone} dalam ${Math.floor(backoffDelay / 1000)} detik...`);
await new Promise(r => setTimeout(r, backoffDelay));
}
}
}
}
global.broadcastStatus.active = false;
logger.info(`[Broadcast] Selesai. Terkirim: ${global.broadcastStatus.sent}, Gagal: ${global.broadcastStatus.failed}`);
};
sendMessageAsync();
req.session._msg = { type: 'success', text: `Broadcast sedang diproses untuk dikirim ke ${uniqueCustomers.length} pelanggan dengan smart rate limit.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal Broadcast: ' + e.message };
}
res.redirect('/admin/whatsapp/broadcast');
});
router.post('/whatsapp/auto-billing', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const enabled = req.body && req.body.enabled ? true : false;
const billingEnabled = req.body && req.body.billing_enabled ? true : false;
const delay = req.body && req.body.delay ? parseInt(req.body.delay) : null;
const next = { whatsapp_auto_billing_enabled: enabled, whatsapp_billing_to_customer_enabled: billingEnabled };
if (delay != null && Number.isFinite(delay) && delay >= 1 && delay <= 60) {
next.whatsapp_broadcast_delay = delay;
}
if (req.body && req.body.reminder_days_before != null) {
const rd = String(req.body.reminder_days_before).replace(/[^\d,]/g, '').replace(/,+/g, ',').replace(/^,|,$/g, '');
if (rd) next.reminder_days_before = rd;
}
if (req.body && req.body.auto_billing_template_key) {
next.auto_billing_template_key = String(req.body.auto_billing_template_key).trim();
}
const msg = req.body && typeof req.body.message === 'string' ? req.body.message.trim() : '';
if (msg) {
db.saveAppSetting('whatsapp_auto_billing_message', msg);
}
saveSettings(next);
req.session._msg = { type: 'success', text: `Pengingat tagihan otomatis ${enabled ? 'diaktifkan' : 'dimatikan'}. Notifikasi tagihan ke pelanggan ${billingEnabled ? 'diaktifkan' : 'dimatikan'}.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal menyimpan pengaturan: ' + e.message };
}
res.redirect('/admin/whatsapp/broadcast');
});
// Jalankan pengingat tagihan otomatis sekarang (manual, tanpa nunggu CRON)
router.post('/whatsapp/run-reminder-now', requireAdminSession, async (req, res) => {
try {
const r = await require('../services/cronService').runAutoBillingReminder();
const msg = (r && r.skipped)
? ('Pengingat dilewati (' + r.skipped + '). Pastikan opsi otomatis & WA aktif.')
: ('Pengingat dijalankan: target ' + ((r && r.target) || 0) + ', terkirim ' + ((r && r.sent) || 0) + ', gagal ' + ((r && r.failed) || 0) + '.');
return res.json({ ok: true, message: msg, data: r || null });
} catch (e) {
return res.status(500).json({ ok: false, message: e.message });
}
});
router.get('/api/whatsapp/status', requireAdmin, async (req, res) => {
try {
const gatewayType = getSetting('wa_gateway_type', 'gowa');
if (gatewayType === 'fonnte') {
const token = getSetting('fonnte_token', '');
res.json({
connection: token ? 'open' : 'close',
gateway: 'fonnte',
user: { id: 'fonnte_gateway', name: 'Fonnte Gateway' },
lastUpdate: new Date().toISOString()
});
} else if (gatewayType === 'meta') {
const phoneId = getSetting('meta_phone_number_id', '');
const token = getSetting('meta_access_token', '');
res.json({ connection: (phoneId && token) ? 'open' : 'connecting', gateway: 'meta' });
} else if (gatewayType === 'gowa') {
let st = null;
try { st = await require('../services/gowaWhatsappService').gowaStatus(); } catch (e) { st = { ok: false, error: e.message }; }
const rr = (st && st.data && st.data.results) || {};
const loggedIn = !!(rr.is_logged_in || rr.isConnected || rr.connected);
const isConn = !!(rr.is_connected || rr.connected);
const conn = isConn ? 'open' : (loggedIn ? 'connecting' : 'close');
res.json({ connection: conn, gateway: 'gowa', loggedIn: loggedIn, user: { id: 'gowa_gateway', name: loggedIn ? 'GOWA (WhatsApp)' : 'GOWA Gateway' }, lastUpdate: new Date().toISOString() });
} else if (gatewayType === 'baileys') {
const { whatsappStatus, startWhatsAppBot, isBotRunning } = await import('../services/whatsappBot.mjs');
if (getSetting('whatsapp_enabled', true) && !isBotRunning() && whatsappStatus.connection !== 'open' && whatsappStatus.connection !== 'qr') {
startWhatsAppBot().catch(err => logger.error('Auto start Baileys error: ' + err.message));
}
res.json(whatsappStatus);
} else {
res.json({ connection: 'close', gateway: gatewayType || 'unknown' });
}
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/whatsapp/test-notification', requireAdminSession, async (req, res) => {
try {
const waSvc = require('../services/whatsappService');
const adminNumbers = getSetting('whatsapp_admin_numbers', []);
const legacyNumbers = getSetting('admins', []);
let adminPhone = getSetting('company_phone', '');
if (Array.isArray(adminNumbers) && adminNumbers.length > 0) {
adminPhone = adminNumbers[0];
} else if (Array.isArray(legacyNumbers) && legacyNumbers.length > 0) {
adminPhone = legacyNumbers[0];
}
if (!adminPhone) {
throw new Error('Nomor WhatsApp admin belum dikonfigurasi di Pengaturan WhatsApp / Perusahaan.');
}
logger.info(`[WA Test] Mengirim test notifikasi ke nomor admin: ${adminPhone}`);
const msg =
`🧪 *TEST NOTIFIKASI WHATSAPP*\n\n` +
`✅ Jika pesan ini masuk, berarti notifikasi WhatsApp dari Billing System sudah berfungsi dengan baik.\n` +
`📅 Waktu: ${getNowLocal()}`;
const ok = await waSvc.sendWhatsAppMessage(adminPhone, msg);
if (!ok) throw new Error('Gagal mengirim pesan test WhatsApp.');
logger.info(`[WA Test] Test notifikasi sukses terkirim ke ${adminPhone}`);
req.session._msg = { type: 'success', text: 'Test notifikasi WhatsApp berhasil dikirim ke ' + adminPhone };
} catch (e) {
logger.error(`[WA Test] Gagal mengirim test notifikasi: ${e.message}`);
req.session._msg = { type: 'error', text: 'Gagal kirim test WhatsApp: ' + e.message };
}
res.redirect('/admin/whatsapp');
});
router.post('/whatsapp/reset', requireAdminSession, (req, res) => {
try {
const authFolder = getSetting('whatsapp_auth_folder', 'auth_info_baileys');
const folderPath = path.resolve(__dirname, '..', authFolder);
if (fs.existsSync(folderPath)) {
fs.rmSync(folderPath, { recursive: true, force: true });
logger.info(`[WA] Session reset by admin. Folder ${authFolder} deleted.`);
// Trigger restart bot secara asinkron
import('../services/whatsappBot.mjs').then(m => m.restartWhatsAppBot()).catch(e => {
logger.error('Failed to trigger WA restart:', e.message);
});
req.session._msg = { text: 'Sesi WhatsApp berhasil dihapus. Bot sedang memulai ulang, silakan tunggu QR Code muncul.', type: 'success' };
} else {
req.session._msg = { text: 'Folder sesi tidak ditemukan atau sudah dihapus.', type: 'warning' };
}
res.redirect('/admin/whatsapp');
} catch (e) {
logger.error('Failed to reset WA session:', e.message);
req.session._msg = { text: 'Gagal menghapus sesi: ' + e.message, type: 'danger' };
res.redirect('/admin/whatsapp');
}
});
router.post('/whatsapp/repair-keys', requireAdminSession, async (req, res) => {
try {
const { cleanStaleSessionKeys, restartWhatsAppBot } = await import('../services/whatsappBot.mjs');
const cleaned = cleanStaleSessionKeys();
await restartWhatsAppBot();
req.session._msg = {
text: `Berhasil membersihkan ${cleaned} cache session/pre-key usang. Bot Baileys telah di-refresh tanpa perlu scan QR ulang.`,
type: 'success'
};
res.redirect('/admin/whatsapp');
} catch (e) {
logger.error('Failed to repair WA session keys:', e.message);
req.session._msg = { text: 'Gagal memperbaiki session keys: ' + e.message, type: 'danger' };
res.redirect('/admin/whatsapp');
}
});
// ─── ROUTERS (MULTI-ROUTER) ──────────────────────────────────────────────────
router.get('/routers', requireAdminSession, requireSidebarMenuAccess('mikrotik'), (req, res) => {
res.render('admin/routers', {
title: 'Manajemen Router', company: company(), activePage: 'mikrotik',
routers: mikrotikService.getAllRouters(), msg: flashMsg(req)
});
});
// ─── PROMO SLIDES ─────────────────────────────────────────────────────────────
router.get('/promo-slides', requireAdminSession, requireSidebarMenuAccess('settings'), (req, res) => {
try {
const slides = db.prepare(`
SELECT * FROM promo_slides
ORDER BY sort_order ASC, id ASC
`).all();
res.render('admin/promo_slides', {
title: 'Manajemen Promo Slides',
company: company(),
activePage: 'promo_slides',
slides: slides || [],
msg: flashMsg(req)
});
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
res.redirect('/admin/settings');
}
});
router.get('/api/promo-slides/:id', requireAdminSession, (req, res) => {
try {
const slide = db.prepare('SELECT * FROM promo_slides WHERE id = ?').get(Number(req.params.id));
if (!slide) return res.status(404).json({ error: 'Slide tidak ditemukan' });
res.json(slide);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post('/promo-slides', requireAdminSession, requireSidebarMenuAccess('settings'), promoUpload.single('image'), async (req, res) => {
try {
const { title, description, url, open_in_new_tab, sort_order, start_date, end_date, is_active } = req.body;
if (!req.file) throw new Error('Gambar harus diupload');
let imagePath = `/uploads/promo_slides/${req.file.filename}`;
// Konversi otomatis PNG/JPEG -> WebP (lebih ringan)
try {
const _opt = await require('../services/imageOptimize').convertUploadToWebp(req.file.path, '/uploads/promo_slides', { maxWidth: 1600, quality: 82 });
if (_opt && _opt.urlPath) imagePath = _opt.urlPath;
} catch (e) { try { logger.warn('[Promo] Konversi WebP gagal: ' + e.message); } catch (_) {} }
db.prepare(`
INSERT INTO promo_slides (title, description, image_path, url, open_in_new_tab, sort_order, start_date, end_date, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
String(title || '').trim(),
String(description || '').trim(),
imagePath,
String(url || '').trim(),
open_in_new_tab === 'on' ? 1 : 0,
Number(sort_order) || 0,
start_date || null,
end_date || null,
is_active === 'on' ? 1 : 0
);
req.session._msg = { type: 'success', text: 'Promo slide berhasil ditambahkan' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/promo-slides');
});
router.post('/promo-slides/:id/update', requireAdminSession, requireSidebarMenuAccess('settings'), promoUpload.single('image'), async (req, res) => {
try {
const { title, description, url, open_in_new_tab, sort_order, start_date, end_date, is_active } = req.body;
const slideId = Number(req.params.id);
const slide = db.prepare('SELECT * FROM promo_slides WHERE id = ?').get(slideId);
if (!slide) throw new Error('Slide tidak ditemukan');
let imagePath = slide.image_path;
let oldImagePath = null;
if (req.file) {
oldImagePath = slide.image_path;
imagePath = `/uploads/promo_slides/${req.file.filename}`;
try {
const _opt2 = await require('../services/imageOptimize').convertUploadToWebp(req.file.path, '/uploads/promo_slides', { maxWidth: 1600, quality: 82 });
if (_opt2 && _opt2.urlPath) imagePath = _opt2.urlPath;
} catch (e) { try { logger.warn('[Promo] Konversi WebP gagal: ' + e.message); } catch (_) {} }
}
db.prepare(`
UPDATE promo_slides SET
title = ?, description = ?, image_path = ?, url = ?,
open_in_new_tab = ?, sort_order = ?, start_date = ?, end_date = ?, is_active = ?,
updated_at = NOW_LOCAL()
WHERE id = ?
`).run(
String(title || '').trim(),
String(description || '').trim(),
imagePath,
String(url || '').trim(),
open_in_new_tab === 'on' ? 1 : 0,
Number(sort_order) || 0,
start_date || null,
end_date || null,
is_active === 'on' ? 1 : 0,
slideId
);
// Cleanup old image file if new one was uploaded
if (oldImagePath && oldImagePath !== imagePath) {
const oldFilePath = path.resolve(__dirname, '..', 'public', oldImagePath);
try {
if (fs.existsSync(oldFilePath)) {
fs.unlinkSync(oldFilePath);
logger.info(`[PromoSlides] Deleted old image: ${oldFilePath}`);
}
} catch (err) {
logger.warn(`[PromoSlides] Failed to delete old image: ${err.message}`);
// Don't fail the request, just log warning
}
}
req.session._msg = { type: 'success', text: 'Promo slide berhasil diperbarui' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/promo-slides');
});
router.post('/promo-slides/:id/delete', requireAdminSession, requireSidebarMenuAccess('settings'), (req, res) => {
try {
const slideId = Number(req.params.id);
// Get slide data to retrieve image path for cleanup
const slide = db.prepare('SELECT * FROM promo_slides WHERE id = ?').get(slideId);
// Delete from database
db.prepare('DELETE FROM promo_slides WHERE id = ?').run(slideId);
// Cleanup image file
if (slide && slide.image_path) {
const filePath = path.resolve(__dirname, '..', 'public', slide.image_path);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
logger.info(`[PromoSlides] Deleted image: ${filePath}`);
}
} catch (err) {
logger.warn(`[PromoSlides] Failed to delete image: ${err.message}`);
// Don't fail the request, just log warning
}
}
req.session._msg = { type: 'success', text: 'Promo slide berhasil dihapus' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/promo-slides');
});
router.post('/routers', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
mikrotikService.createRouter(req.body);
req.session._msg = { type: 'success', text: `Router "${req.body.name}" berhasil ditambahkan.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/routers');
});
router.post('/routers/:id/update', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
mikrotikService.updateRouter(req.params.id, req.body);
req.session._msg = { type: 'success', text: 'Router berhasil diperbarui.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/routers');
});
router.post('/routers/:id/delete', requireAdminSession, (req, res) => {
try {
mikrotikService.deleteRouter(req.params.id);
req.session._msg = { type: 'success', text: 'Router berhasil dihapus.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/routers');
});
router.get('/api/routers/:id/test', requireAdmin, async (req, res) => {
try {
const conn = await mikrotikService.getConnection(req.params.id);
if (conn && conn.api) {
conn.api.close();
return res.json({ success: true, message: 'Koneksi ke Router Berhasil!' });
}
throw new Error('Gagal terhubung ke router');
} catch (e) {
res.json({ success: false, error: e.message });
}
});
router.get('/api/routers/:id/details', requireAdmin, async (req, res) => {
try {
const routerId = req.params.id;
const routerConfig = mikrotikService.getRouterById(routerId);
if (!routerConfig) return res.status(404).json({ success: false, error: 'Router tidak ditemukan' });
let liveData = {
connected: false,
identity: routerConfig.name || 'MikroTik',
version: '-',
boardName: '-',
cpuLoad: 0,
freeMemory: 0,
totalMemory: 0,
freeHdd: 0,
totalHdd: 0,
uptime: '-',
architecture: '-',
cpuCount: 1,
cpuFrequency: 0,
activePppoe: 0,
activeHotspot: 0,
customersCount: 0,
error: null
};
try {
const allRouters = mikrotikService.getAllRouters();
const firstRouter = allRouters && allRouters.length > 0 ? allRouters[0] : null;
let countRes;
if (firstRouter && Number(firstRouter.id) === Number(routerId)) {
countRes = db.prepare('SELECT COUNT(*) as total FROM customers WHERE router_id = ? OR router_id IS NULL OR router_id = 0').get(routerId);
} else {
countRes = db.prepare('SELECT COUNT(*) as total FROM customers WHERE router_id = ?').get(routerId);
}
liveData.customersCount = countRes ? countRes.total : 0;
} catch (e) {
liveData.customersCount = 0;
}
try {
const resource = await mikrotikService.getSystemResource(routerId);
if (resource) {
liveData.connected = true;
liveData.cpuLoad = parseInt(resource['cpu-load'] || resource.cpuLoad || 0);
liveData.freeMemory = parseInt(resource['free-memory'] || resource.freeMemory || 0);
liveData.totalMemory = parseInt(resource['total-memory'] || resource.totalMemory || 0);
liveData.freeHdd = parseInt(resource['free-hdd-space'] || resource.freeHddSpace || 0);
liveData.totalHdd = parseInt(resource['total-hdd-space'] || resource.totalHddSpace || 0);
liveData.version = resource.version || '-';
liveData.boardName = resource['board-name'] || resource.boardName || '-';
liveData.uptime = resource.uptime || '-';
liveData.architecture = resource['architecture-name'] || resource.architectureName || '-';
liveData.cpuCount = resource['cpu-count'] || resource.cpuCount || 1;
liveData.cpuFrequency = resource['cpu-frequency'] || resource.cpuFrequency || 0;
}
try {
const identity = await mikrotikService.getSystemIdentity(routerId);
if (identity) liveData.identity = identity;
} catch (e) {}
try {
const activePpp = await mikrotikService.getPppoeActive(routerId);
liveData.activePppoe = activePpp ? activePpp.length : 0;
liveData.activePppoeList = Array.isArray(activePpp) ? activePpp.slice(0, 50).map(s => ({
name: s.name || s['name'] || '-',
address: s.address || s['address'] || '-',
uptime: s.uptime || s['uptime'] || '-',
callerId: s['caller-id'] || s.callerId || '-'
})) : [];
} catch (e) {
liveData.activePppoeList = [];
}
try {
const activeHs = await mikrotikService.getHotspotActive(routerId);
liveData.activeHotspot = activeHs ? activeHs.length : 0;
liveData.activeHotspotList = Array.isArray(activeHs) ? activeHs.slice(0, 50).map(h => ({
user: h.user || h['user'] || '-',
address: h.address || h['address'] || '-',
mac: h['mac-address'] || h.mac || '-',
uptime: h.uptime || h['uptime'] || '-'
})) : [];
} catch (e) {
liveData.activeHotspotList = [];
}
} catch (e) {
liveData.connected = false;
liveData.error = e.message;
}
res.json({ success: true, router: routerConfig, live: liveData });
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
router.post('/api/routers/:id/setup-firewall', requireAdmin, async (req, res) => {
try {
const result = await mikrotikService.setupIsolirFirewall(req.params.id);
res.json(result);
} catch (e) {
res.json({ success: false, error: e.message });
}
});
router.get('/api/isolir-portal-script', requireAdmin, async (req, res) => {
try {
const data = await mikrotikService.generateIsolirPortalScript();
res.json({ success: true, ...data });
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
router.post('/api/system/update', requireAdmin, async (req, res) => {
const { exec } = require('child_process');
const path = require('path');
const projectDir = path.join(__dirname, '..');
const isWindows = process.platform === 'win32';
const cmd = isWindows
? 'git pull origin main || git pull'
: 'nice -n 19 bash update.sh';
logger.info(`[System Update] Admin initiated 1-Click System Update.`);
exec(cmd, { cwd: projectDir, timeout: 120000 }, (error, stdout, stderr) => {
if (error) {
logger.error(`[System Update Error]: ${error.message}`);
return res.json({ success: false, message: 'Gagal update: ' + error.message, output: stderr || stdout });
}
logger.info(`[System Update Complete]: ${stdout}`);
return res.json({
success: true,
message: 'Pembaruan aplikasi berhasil diterapkan (Low-CPU Mode)!',
output: stdout || 'Git pull & reload berhasil.'
});
});
});
router.get('/api/mikrotik/profiles/:routerId', requireAdmin, async (req, res) => {
try {
const profiles = await mikrotikService.getPppoeProfiles(req.params.routerId);
res.json(profiles);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/api/mikrotik/users/:routerId', requireAdmin, async (req, res) => {
try {
const routerId = req.params.routerId ? Number(req.params.routerId) : null;
const onlyUnused = String(req.query.onlyUnused || '') === '1';
const excludeCustomerId = req.query.excludeCustomerId ? Number(req.query.excludeCustomerId) : null;
const users = await mikrotikService.getPppoeUsers(routerId);
if (!onlyUnused) return res.json(users);
const rows = excludeCustomerId
? db.prepare("SELECT pppoe_username FROM customers WHERE router_id IS ? AND id != ? AND pppoe_username IS NOT NULL AND TRIM(pppoe_username) != ''").all(routerId, excludeCustomerId)
: db.prepare("SELECT pppoe_username FROM customers WHERE router_id IS ? AND pppoe_username IS NOT NULL AND TRIM(pppoe_username) != ''").all(routerId);
const used = new Set(rows.map(r => String(r.pppoe_username).trim()).filter(Boolean));
const filtered = (Array.isArray(users) ? users : []).filter(u => u && u.name && !used.has(String(u.name).trim()));
res.json(filtered);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── ATTENDANCE MANAGEMENT ───────────────────────────────────────────────────
// Attendance dashboard
router.get('/attendance', requireAdminSession, requireSidebarMenuAccess('attendance'), (req, res) => {
try {
const date = req.query.date || getNowLocal().split(' ')[0];
const attendances = attendanceSvc.getAttendanceByDate(date);
const stats = attendanceSvc.getAttendanceStats(date);
const lateCheckIns = attendanceSvc.getLateCheckIns(date);
const notCheckedOut = attendanceSvc.getNotCheckedOut(date);
res.render('admin/attendance', {
title: 'Manajemen Absensi',
company: company(),
activePage: 'attendance',
session: req.session,
attendances,
stats,
lateCheckIns,
notCheckedOut,
selectedDate: date,
msg: flashMsg(req),
t: (key, defaultVal) => defaultVal || key
});
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal memuat data absensi: ' + e.message };
res.redirect('/admin');
}
});
// Get attendance by date range (API)
router.get('/api/attendance/range', requireAdminSession, (req, res) => {
try {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.json({ success: false, message: 'Start date dan end date wajib diisi' });
}
const attendances = attendanceSvc.getAttendanceByDateRange(startDate, endDate);
res.json({ success: true, data: attendances });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
// Get employee attendance history
router.get('/api/attendance/employee/:type/:id', requireAdminSession, (req, res) => {
try {
const { type, id } = req.params;
const limit = req.query.limit ? parseInt(req.query.limit) : 30;
const history = attendanceSvc.getAttendanceHistory(type, parseInt(id), limit);
res.json({ success: true, data: history });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
// Get monthly summary
router.get('/api/attendance/summary/:type/:id/:year/:month', requireAdminSession, (req, res) => {
try {
const { type, id, year, month } = req.params;
const summary = attendanceSvc.getMonthlyAttendanceSummary(
type,
parseInt(id),
parseInt(year),
parseInt(month)
);
res.json({ success: true, data: summary });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
// Update attendance (admin correction)
router.post('/attendance/:id/update', requireAdminSession, express.json(), (req, res) => {
try {
const { id } = req.params;
const { check_in_time, check_in_note, check_out_time, check_out_note } = req.body;
// Calculate duration if both times provided
let duration = 0;
if (check_in_time && check_out_time) {
const checkIn = parseDateInTimezone(check_in_time);
const checkOut = parseDateInTimezone(check_out_time);
if (checkIn && checkOut) {
duration = Math.floor((checkOut - checkIn) / 1000 / 60);
}
}
attendanceSvc.updateAttendance(parseInt(id), {
check_in_time,
check_in_note: check_in_note || '',
check_out_time: check_out_time || null,
check_out_note: check_out_note || '',
work_duration_minutes: duration
});
auditSvc.log('admin', req.session.username || 'admin', 'update_attendance', `Updated attendance #${id}`);
res.json({ success: true, message: 'Absensi berhasil diperbarui' });
} catch (e) {
res.json({ success: false, message: 'Gagal update absensi: ' + e.message });
}
});
// Delete attendance
router.post('/attendance/:id/delete', requireAdminSession, (req, res) => {
try {
const { id } = req.params;
attendanceSvc.deleteAttendance(parseInt(id));
auditSvc.log('admin', req.session.username || 'admin', 'delete_attendance', `Deleted attendance #${id}`);
req.session._msg = { type: 'success', text: 'Absensi berhasil dihapus' };
res.redirect('/admin/attendance');
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal hapus absensi: ' + e.message };
res.redirect('/admin/attendance');
}
});
// Export attendance to Excel
router.get('/attendance/export', requireAdminSession, (req, res) => {
try {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
req.session._msg = { type: 'error', text: 'Tanggal mulai dan akhir wajib diisi' };
return res.redirect('/admin/attendance');
}
const attendances = attendanceSvc.getAttendanceByDateRange(startDate, endDate);
// Prepare data for Excel
const data = attendances.map(a => ({
'ID': a.id,
'Tipe Karyawan': a.employee_type,
'Nama': a.employee_name,
'Check In': a.check_in_time,
'Lokasi Check In': a.check_in_lat && a.check_in_lng ? `${a.check_in_lat}, ${a.check_in_lng}` : '-',
'Catatan Check In': a.check_in_note || '-',
'Foto Check In': a.check_in_photo || '-',
'Check Out': a.check_out_time || '-',
'Lokasi Check Out': a.check_out_lat && a.check_out_lng ? `${a.check_out_lat}, ${a.check_out_lng}` : '-',
'Catatan Check Out': a.check_out_note || '-',
'Foto Check Out': a.check_out_photo || '-',
'Durasi (menit)': a.work_duration_minutes || 0,
'Status': a.status
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Absensi');
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
res.setHeader('Content-Disposition', `attachment; filename=absensi_${startDate}_${endDate}.xlsx`);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.send(buffer);
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal export: ' + e.message };
res.redirect('/admin/attendance');
}
});
// ─── PAYROLL / GAJI KARYAWAN ───────────────────────────────────────────────
router.get('/payroll', requireAdmin, requireSidebarMenuAccess('payroll'), (req, res) => {
const now = new Date();
const month = parseInt(req.query.month) || (now.getMonth() + 1);
const year = parseInt(req.query.year) || now.getFullYear();
const employees = payrollSvc.getAllEmployees();
const slips = payrollSvc.getSlipsByPeriod(month, year);
const summary = payrollSvc.getPayrollSummary(month, year);
res.render('admin/payroll', {
title: 'Gaji Karyawan',
company: company(),
employees,
slips,
summary,
selectedMonth: month,
selectedYear: year,
msg: req.session._msg || null
});
req.session._msg = null;
});
router.post('/payroll/settings', requireAdmin, (req, res) => {
try {
payrollSvc.upsertPayrollSetting(req.body);
req.session._msg = { type: 'success', text: 'Pengaturan gaji berhasil disimpan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/payroll');
});
router.post('/payroll/generate', requireAdmin, (req, res) => {
const month = parseInt(req.body.month);
const year = parseInt(req.body.year);
if (!month || !year) {
req.session._msg = { type: 'error', text: 'Bulan dan tahun diperlukan' };
return res.redirect('/admin/payroll');
}
const result = payrollSvc.generateAllSlips(month, year);
req.session._msg = {
type: 'success',
text: `Generate selesai: ${result.generated} berhasil, ${result.skipped} dilewati, ${result.errors.length} error.`
};
res.redirect(`/admin/payroll?month=${month}&year=${year}`);
});
router.post('/payroll/slip/:id/deduction', requireAdmin, express.json(), (req, res) => {
try {
const { other_deduction, other_deduction_note } = req.body;
payrollSvc.updateSlipDeductions(req.params.id, other_deduction, other_deduction_note);
res.json({ success: true, message: 'Potongan diperbarui' });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
router.post('/payroll/slip/:id/approve', requireAdmin, (req, res) => {
try {
payrollSvc.approveSlip(req.params.id);
req.session._msg = { type: 'success', text: 'Slip berhasil di-approve.' };
} catch (e) {
req.session._msg = { type: 'error', text: e.message };
}
res.redirect('back');
});
router.post('/payroll/slip/:id/paid', requireAdmin, (req, res) => {
try {
payrollSvc.markSlipPaid(req.params.id);
req.session._msg = { type: 'success', text: 'Slip ditandai lunas (paid).' };
} catch (e) {
req.session._msg = { type: 'error', text: e.message };
}
res.redirect('back');
});
router.post('/payroll/slip/:id/delete', requireAdmin, (req, res) => {
try {
payrollSvc.deleteSlip(req.params.id);
req.session._msg = { type: 'success', text: 'Slip berhasil dihapus.' };
} catch (e) {
req.session._msg = { type: 'error', text: e.message };
}
res.redirect('back');
});
router.post('/payroll/bulk-approve', requireAdmin, (req, res) => {
try {
payrollSvc.bulkApprove(parseInt(req.body.month), parseInt(req.body.year));
req.session._msg = { type: 'success', text: 'Semua slip draft berhasil di-approve.' };
} catch (e) {
req.session._msg = { type: 'error', text: e.message };
}
res.redirect('back');
});
router.post('/payroll/bulk-paid', requireAdmin, (req, res) => {
try {
payrollSvc.bulkMarkPaid(parseInt(req.body.month), parseInt(req.body.year));
req.session._msg = { type: 'success', text: 'Semua slip approved ditandai lunas.' };
} catch (e) {
req.session._msg = { type: 'error', text: e.message };
}
res.redirect('back');
});
router.post('/payroll/delete-drafts', requireAdmin, (req, res) => {
try {
payrollSvc.deleteSlipsByPeriod(parseInt(req.body.month), parseInt(req.body.year));
req.session._msg = { type: 'success', text: 'Semua slip draft dihapus.' };
} catch (e) {
req.session._msg = { type: 'error', text: e.message };
}
res.redirect('back');
});
router.get('/payroll/slip/:id/print', requireAdmin, (req, res) => {
const slip = payrollSvc.getSlipById(req.params.id);
if (!slip) return res.status(404).send('Slip tidak ditemukan');
res.render('admin/print_payslip', {
company: company(),
slip
});
});
router.get('/payroll/slip/:id/pdf', requireAdmin, async (req, res) => {
try {
const slip = payrollSvc.getSlipById(req.params.id);
if (!slip) return res.status(404).send('Slip tidak ditemukan');
const pdf = await require('../services/payslipPdfService').generatePayslipPdfBuffer(slip, getSettings());
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="Slip_Gaji_' + (slip.employee_name || slip.id) + '_' + (slip.period_month || '') + '-' + (slip.period_year || '') + '.pdf"');
res.send(pdf);
} catch (e) { res.status(500).send('Gagal membuat PDF: ' + e.message); }
});
router.post('/payroll/slip/:id/send-wa', requireAdmin, async (req, res) => {
try {
const slip = payrollSvc.getSlipById(req.params.id);
if (!slip) throw new Error('Slip tidak ditemukan');
const phone = payrollSvc.getEmployeePhone(slip.employee_type, slip.employee_id);
if (!phone) throw new Error('Nomor HP karyawan tidak diset');
const { getSettingsWithCache } = require('../config/settingsManager');
const settings = getSettingsWithCache();
if (!settings.whatsapp_enabled) throw new Error('WhatsApp bot tidak aktif');
const waNotify = require('../services/waNotifyService');
if (false) {
throw new Error('WhatsApp bot tidak terkoneksi');
}
const monthNames = ['','Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'];
const msg = `🧾 *SLIP GAJI KARYAWAN*\n\n` +
`👤 *Nama:* ${slip.employee_name}\n` +
`📅 *Periode:* ${monthNames[slip.period_month]} ${slip.period_year}\n` +
`🏢 *Status:* ${slip.status.toUpperCase()}\n\n` +
`*PENDAPATAN:*\n` +
`- Gaji Pokok: Rp ${slip.base_salary.toLocaleString('id-ID')}\n` +
(slip.transport_allowance ? `- Tunj. Transport: Rp ${slip.transport_allowance.toLocaleString('id-ID')}\n` : '') +
(slip.meal_allowance ? `- Tunj. Makan: Rp ${slip.meal_allowance.toLocaleString('id-ID')}\n` : '') +
(slip.phone_allowance ? `- Tunj. Pulsa: Rp ${slip.phone_allowance.toLocaleString('id-ID')}\n` : '') +
(slip.other_allowance ? `- Tunj. Lain: Rp ${slip.other_allowance.toLocaleString('id-ID')}\n` : '') +
(slip.ticket_bonus ? `- Bonus Tiket: +Rp ${slip.ticket_bonus.toLocaleString('id-ID')}\n` : '') +
(slip.collection_commission ? `- Komisi Tagihan: +Rp ${slip.collection_commission.toLocaleString('id-ID')}\n` : '') +
(slip.overtime_bonus ? `- Lembur: +Rp ${slip.overtime_bonus.toLocaleString('id-ID')}\n` : '') +
`*Total Pendapatan: Rp ${slip.gross_salary.toLocaleString('id-ID')}*\n\n` +
`*POTONGAN:*\n` +
(slip.absence_deduction ? `- Potongan Absen: -Rp ${slip.absence_deduction.toLocaleString('id-ID')}\n` : '') +
(slip.late_deduction ? `- Potongan Terlambat: -Rp ${slip.late_deduction.toLocaleString('id-ID')}\n` : '') +
(slip.other_deduction ? `- Potongan Lain: -Rp ${slip.other_deduction.toLocaleString('id-ID')}\n` : '') +
`*Total Potongan: Rp ${slip.total_deductions.toLocaleString('id-ID')}*\n\n` +
`💰 *GAJI BERSIH: Rp ${slip.net_salary.toLocaleString('id-ID')}*\n\n` +
`Terima kasih atas kerja keras Anda! 🙏`;
await waNotify.sendText(phone, msg);
res.json({ success: true, message: 'Terkirim' });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
router.use('/acs', acsPortal);
// Mount Finance Portal
router.use('/finance', require('./financePortal'));
// ─── ONU PROVISION ─────────────────────────────────────────────────────────
const onuProvisionSvc = require('../services/onuProvisionService');
router.get('/onu-provision', requireAdminSession, restrictToAdmin, (req, res) => {
const oltConfig = {
vendor: getSetting('olt_vendor', ''),
host: getSetting('olt_host', ''),
port: getSetting('olt_port', 22),
username: getSetting('olt_username', ''),
password: getSetting('olt_password', '')
};
res.render('admin/onu_provision', {
title: 'ONU Provision',
company: company(),
activePage: 'onu_provision',
msg: flashMsg(req),
oltConfig,
lang: req.session?.lang || 'id'
});
});
router.post('/onu-provision/configure-olt', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), async (req, res) => {
try {
const { vendor, host, port, username, password, action } = req.body;
if (action === 'test') {
// Test connection
const oltConfig = { vendor, host, port: parseInt(port), username, password };
try {
const conn = await onuProvisionSvc.connectSSH(oltConfig);
conn.end();
req.session._msg = { type: 'success', text: `Koneksi ke OLT ${vendor} berhasil!` };
} catch (error) {
req.session._msg = { type: 'error', text: `Gagal koneksi: ${error.message}` };
}
} else if (action === 'save') {
// Save configuration
const currentSettings = getSettings();
const success = saveSettings({
...currentSettings,
olt_vendor: vendor,
olt_host: host,
olt_port: parseInt(port),
olt_username: username,
olt_password: password
});
if (success) {
req.session._msg = { type: 'success', text: 'Konfigurasi OLT berhasil disimpan.' };
} else {
req.session._msg = { type: 'error', text: 'Gagal menyimpan konfigurasi OLT.' };
}
}
} catch (error) {
req.session._msg = { type: 'error', text: 'Error: ' + error.message };
}
res.redirect('/admin/onu-provision');
});
router.post('/onu-provision/scan-unconfigured', requireAdminSession, restrictToAdmin, express.json(), async (req, res) => {
try {
const oltConfig = {
vendor: getSetting('olt_vendor', ''),
host: getSetting('olt_host', ''),
port: getSetting('olt_port', 22),
username: getSetting('olt_username', ''),
password: getSetting('olt_password', '')
};
if (!oltConfig.host) {
return res.json({ success: false, error: 'OLT belum dikonfigurasi' });
}
let onus = [];
if (oltConfig.vendor === 'ZTE') {
const { pon } = req.body;
if (!pon) {
return res.json({ success: false, error: 'PON interface harus diisi' });
}
onus = await onuProvisionSvc.zteGetUnconfiguredONUs(oltConfig, pon);
} else if (oltConfig.vendor === 'Huawei') {
const { frame, slot, pon } = req.body;
onus = await onuProvisionSvc.huaweiGetUnconfiguredONUs(oltConfig, frame, slot, pon);
} else if (oltConfig.vendor === 'HSGQ' || oltConfig.vendor === 'Hioso') {
const { pon } = req.body;
if (!pon) {
return res.json({ success: false, error: 'PON interface harus diisi' });
}
onus = await onuProvisionSvc.hsgqGetUnconfiguredONUs(oltConfig, pon);
} else {
return res.json({ success: false, error: 'Vendor OLT tidak didukung untuk scanning otomatis' });
}
res.json({ success: true, onus });
} catch (error) {
logger.error('Scan unconfigured ONUs error:', error);
res.json({ success: false, error: error.message });
}
});
router.post('/onu-provision/scan-configured', requireAdminSession, restrictToAdmin, express.json(), async (req, res) => {
try {
const host = getSetting('olt_host', '');
if (!host) {
return res.json({ success: false, error: 'OLT belum dikonfigurasi di pengaturan global' });
}
const olt = db.prepare('SELECT * FROM olts WHERE host = ? LIMIT 1').get(host);
if (!olt) {
return res.json({
success: false,
error: 'OLT dengan IP ' + host + ' belum didaftarkan di halaman "Manajemen OLT". Silakan daftarkan OLT Anda di sana terlebih dahulu agar sistem dapat membaca data monitoring SNMP.'
});
}
const oltSvc = require('../services/oltService');
const stats = await oltSvc.getOltStats(olt.id, true);
res.json({ success: true, onus: stats.onus || [], oltId: olt.id });
} catch (error) {
logger.error('Scan configured ONUs error:', error);
res.json({ success: false, error: error.message });
}
});
router.post('/onu-provision/provision', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), async (req, res) => {
try {
const oltConfig = {
vendor: getSetting('olt_vendor', ''),
host: getSetting('olt_host', ''),
port: getSetting('olt_port', 22),
username: getSetting('olt_username', ''),
password: getSetting('olt_password', '')
};
if (!oltConfig.host) {
throw new Error('OLT belum dikonfigurasi');
}
const { vendor, createMikrotikPPPoE, mikrotikPppoeUsername, mikrotikPppoePassword, mikrotikProfile } = req.body;
let result;
let messages = [];
// Check if need to create MikroTik PPPoE
if (createMikrotikPPPoE === 'on' && mikrotikPppoeUsername && mikrotikPppoePassword) {
// Validate PPPoE username not already in use
const routerId = req.body.router_id ? Number(req.body.router_id) : null;
const existingCustomer = db.prepare('SELECT id, name FROM customers WHERE router_id IS ? AND pppoe_username = ? LIMIT 1').get(routerId, mikrotikPppoeUsername);
if (existingCustomer) {
throw new Error(`PPPoE Username "${mikrotikPppoeUsername}" sudah digunakan oleh pelanggan: ${existingCustomer.name}`);
}
// Use full provision with MikroTik integration
const mikrotikConfig = {
host: getSetting('mikrotik_host', ''),
user: getSetting('mikrotik_user', ''),
password: getSetting('mikrotik_password', ''),
port: getSetting('mikrotik_port', 8728)
};
if (mikrotikConfig.host) {
// Prepare params for full provision
const provisionParams = {
...req.body,
pppoeUsername: mikrotikPppoeUsername,
pppoePassword: mikrotikPppoePassword,
bandwidth: mikrotikProfile || req.body.bandwidth
};
result = await onuProvisionSvc.fullProvision(oltConfig, mikrotikConfig, provisionParams);
if (result.results.onu) {
messages.push(`✅ ONU ${req.body.name} berhasil di-provision`);
}
if (result.results.pppoe) {
messages.push(`✅ PPPoE ${mikrotikPppoeUsername} berhasil dibuat di MikroTik`);
}
if (result.results.errors && result.results.errors.length > 0) {
messages.push(`⚠️ ${result.results.errors.join(', ')}`);
}
} else {
throw new Error('MikroTik belum dikonfigurasi di settings');
}
} else {
// Standard provision (ONU only)
if (vendor === 'ZTE') {
result = await onuProvisionSvc.zteProvisionONU(oltConfig, req.body);
} else if (vendor === 'Huawei') {
result = await onuProvisionSvc.huaweiProvisionONU(oltConfig, req.body);
} else if (vendor === 'Fiberhome') {
result = await onuProvisionSvc.fiberhomeProvisionONU(oltConfig, req.body);
} else if (vendor === 'VSOL') {
result = await onuProvisionSvc.vsolProvisionONU(oltConfig, req.body);
} else if (vendor === 'CData') {
result = await onuProvisionSvc.cdataProvisionONU(oltConfig, req.body);
} else if (vendor === 'HSGQ') {
result = await onuProvisionSvc.hsgqProvisionONU(oltConfig, req.body);
} else if (vendor === 'Hioso') {
result = await onuProvisionSvc.hiosoProvisionONU(oltConfig, req.body);
} else {
throw new Error('Vendor tidak didukung');
}
messages.push(`✅ ONU ${req.body.name} berhasil di-provision`);
}
if (auditSvc && typeof auditSvc.logAuditTrail === 'function') {
auditSvc.logAuditTrail({
action: 'CREATE',
entity_type: 'onu_provision',
entity_id: req.body.sn,
actor_type: 'admin',
actor_id: String(req.session?.adminUser || ''),
actor_name: req.session?.adminUser || 'Admin',
details: {
vendor,
params: req.body,
mikrotikIntegration: createMikrotikPPPoE === 'on'
},
ip_address: req.ip,
user_agent: req.get('user-agent')
});
}
req.session._msg = { type: 'success', text: messages.join(' | ') };
} catch (error) {
logger.error('Provision ONU error:', error);
req.session._msg = { type: 'error', text: 'Gagal provision ONU: ' + error.message };
}
res.redirect('/admin/onu-provision');
});
router.post('/onu-provision/delete', requireAdminSession, restrictToAdmin, express.json(), async (req, res) => {
try {
const oltConfig = {
vendor: getSetting('olt_vendor', ''),
host: getSetting('olt_host', ''),
port: getSetting('olt_port', 22),
username: getSetting('olt_username', ''),
password: getSetting('olt_password', '')
};
const { vendor, params } = req.body;
const result = await onuProvisionSvc.deleteONU(oltConfig, vendor, params);
if (auditSvc && typeof auditSvc.logAuditTrail === 'function') {
auditSvc.logAuditTrail({
action: 'DELETE',
entity_type: 'onu_provision',
entity_id: params.sn || 'unknown',
actor_type: 'admin',
actor_id: String(req.session?.adminUser || ''),
actor_name: req.session?.adminUser || 'Admin',
details: { vendor, params },
ip_address: req.ip,
user_agent: req.get('user-agent')
});
}
res.json({ success: true, message: result.message });
} catch (error) {
logger.error('Delete ONU error:', error);
res.json({ success: false, error: error.message });
}
});
// --- RADIUS SERVER MANAGEMENT ---
const radiusSvc = require('../services/radiusServerService');
router.get('/radius-settings', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const radiusStatus = radiusSvc.getStatus();
const onlineSessions = radiusSvc.getOnlineSessions();
const acctLogs = radiusSvc.getAccountingLogs(100);
const nasList = db.prepare(`SELECT * FROM radius_nas ORDER BY id DESC`).all() || [];
const todayStats = db.prepare(`
SELECT
COUNT(1) as total_events,
COALESCE(SUM(input_octets + output_octets), 0) as total_bytes
FROM radius_accounting
WHERE DATE(created_at) = DATE('now')
`).get() || { total_events: 0, total_bytes: 0 };
const todayTrafficMB = (todayStats.total_bytes / (1024 * 1024)).toFixed(1);
const todayEvents = todayStats.total_events;
const msg = req.session._msg || null;
req.session._msg = null;
let detectedServerIp = '192.168.8.88';
try {
const ifaces = os.networkInterfaces();
for (const ifaceName in ifaces) {
for (const alias of ifaces[ifaceName]) {
if (alias.family === 'IPv4' && !alias.internal && alias.address !== '127.0.0.1') {
detectedServerIp = alias.address;
break;
}
}
if (detectedServerIp !== '192.168.8.88') break;
}
} catch (e) {}
res.render('admin/radius-settings', {
title: 'Pengaturan RADIUS',
company: company(),
activePage: 'radius_settings',
session: req.session,
radiusStatus,
onlineSessions,
acctLogs,
nasList,
todayTrafficMB,
todayEvents,
serverIp: detectedServerIp,
msg
});
} catch (error) {
logger.error('Error rendering RADIUS settings page:', error);
res.status(500).send('Internal Server Error: ' + error.message);
}
});
router.post('/radius-settings', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const {
radius_enabled,
radius_secret,
radius_auth_port,
radius_acct_port,
radius_isolir_action,
radius_isolir_pool,
radius_isolir_rate_limit,
radius_isolir_ip_pool_enabled,
radius_isolir_ip_pool_start,
radius_isolir_ip_pool_end,
radius_limit_simultaneous,
radius_default_rate_limit,
radius_ip_pool_enabled,
radius_ip_pool_start,
radius_ip_pool_end,
radius_framed_pool,
pppoe_auto_generate,
pppoe_username_pattern,
pppoe_domain,
pppoe_default_password,
radius_use_profile
} = req.body;
saveSettings({
radius_enabled: radius_enabled === '1' ? '1' : '0',
radius_secret: String(radius_secret || 'secret123').trim(),
radius_auth_port: String(radius_auth_port || '1812').trim(),
radius_acct_port: String(radius_acct_port || '1813').trim(),
radius_isolir_action: String(radius_isolir_action || 'pool').trim(),
radius_isolir_pool: String(radius_isolir_pool || 'isolir').trim(),
radius_isolir_rate_limit: String(radius_isolir_rate_limit || '512k/512k').trim(),
radius_isolir_ip_pool_enabled: radius_isolir_ip_pool_enabled === '1' ? '1' : '0',
radius_isolir_ip_pool_start: String(radius_isolir_ip_pool_start || '10.10.99.2').trim(),
radius_isolir_ip_pool_end: String(radius_isolir_ip_pool_end || '10.10.99.254').trim(),
radius_limit_simultaneous: radius_limit_simultaneous === '1' ? '1' : '0',
radius_default_rate_limit: String(radius_default_rate_limit || '5M/10M').trim(),
radius_ip_pool_enabled: radius_ip_pool_enabled === '1' ? '1' : '0',
radius_ip_pool_start: String(radius_ip_pool_start || '10.10.10.2').trim(),
radius_ip_pool_end: String(radius_ip_pool_end || '10.10.10.254').trim(),
radius_framed_pool: String(radius_framed_pool || 'pool-pppoe').trim(),
pppoe_auto_generate: pppoe_auto_generate === '0' ? '0' : '1',
pppoe_username_pattern: String(pppoe_username_pattern || 'name').trim() === 'customer_code' ? 'customer_code' : 'name',
pppoe_domain: String(pppoe_domain || 'sky.id').replace(/^@/, '').trim() || 'sky.id',
pppoe_default_password: String(pppoe_default_password || 'openisp').trim() || 'openisp',
radius_use_profile: radius_use_profile === '1' ? '1' : '0'
});
// Kontrol background service UDP RADIUS
radiusSvc.stop();
if (radius_enabled === '1') {
radiusSvc.start();
}
req.session._msg = { type: 'success', text: 'Pengaturan RADIUS Server berhasil diperbarui.' };
} catch (error) {
logger.error('Error saving RADIUS settings:', error);
req.session._msg = { type: 'danger', text: 'Gagal menyimpan pengaturan: ' + error.message };
}
res.redirect('/admin/radius-settings');
});
router.post('/radius/disconnect', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const { username, session_id, nas_ip } = req.body;
if (!username) throw new Error('Username tidak boleh kosong');
await radiusSvc.disconnectSession(username, session_id, nas_ip);
req.session._msg = { type: 'success', text: `Sesi aktif RADIUS untuk user "${username}" berhasil diputus.` };
} catch (e) {
logger.error('Error disconnecting RADIUS session:', e);
req.session._msg = { type: 'danger', text: 'Gagal memutus sesi RADIUS: ' + e.message };
}
res.redirect('/admin/radius-settings');
});
router.post('/radius/clear-stale', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const result = db.prepare(`
UPDATE radius_accounting
SET status_type = 2, terminate_cause = 10, updated_at = NOW_LOCAL()
WHERE status_type IN (1, 3) AND updated_at < datetime('now', '-2 hours')
`).run();
req.session._msg = { type: 'success', text: `Berhasil membersihkan ${result.changes} sesi kadaluarsa / ghost.` };
} catch (e) {
logger.error('Error clearing stale RADIUS sessions:', e);
req.session._msg = { type: 'danger', text: 'Gagal membersihkan sesi: ' + e.message };
}
res.redirect('/admin/radius-settings');
});
router.post('/radius/restart', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
radiusSvc.stop();
await new Promise(r => setTimeout(r, 500));
radiusSvc.start();
req.session._msg = { type: 'success', text: 'Layanan RADIUS Server (UDP Port 1812/1813) berhasil direstart.' };
} catch (e) {
logger.error('Error restarting RADIUS service:', e);
req.session._msg = { type: 'danger', text: 'Gagal merestart layanan RADIUS: ' + e.message };
}
res.redirect('/admin/radius-settings');
});
router.post('/radius/nas/add', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const { nasname, shortname, secret, description } = req.body;
if (!nasname || !secret) {
throw new Error('IP NAS & Shared Secret wajib diisi.');
}
db.prepare(`
INSERT INTO radius_nas (nasname, shortname, secret, description, is_active)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT(nasname) DO UPDATE SET
shortname = excluded.shortname,
secret = excluded.secret,
description = excluded.description,
is_active = 1
`).run(
String(nasname).trim(),
String(shortname || '').trim(),
String(secret).trim(),
String(description || '').trim()
);
req.session._msg = { type: 'success', text: `NAS ${nasname} berhasil ditambahkan.` };
} catch (error) {
logger.error('Error adding NAS:', error);
req.session._msg = { type: 'danger', text: 'Gagal menambah NAS: ' + error.message };
}
res.redirect('/admin/radius-settings');
});
router.post('/radius/nas/edit', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const { id, nasname, shortname, secret, description, is_active } = req.body;
if (!id || !nasname || !secret) {
throw new Error('ID, IP NAS & Shared Secret wajib diisi.');
}
db.prepare(`
UPDATE radius_nas
SET nasname = ?, shortname = ?, secret = ?, description = ?, is_active = ?, updated_at = (NOW_LOCAL())
WHERE id = ?
`).run(
String(nasname).trim(),
String(shortname || '').trim(),
String(secret).trim(),
String(description || '').trim(),
is_active === '1' || is_active === 1 ? 1 : 0,
id
);
req.session._msg = { type: 'success', text: `Data NAS ${nasname} berhasil diperbarui.` };
} catch (error) {
logger.error('Error updating NAS:', error);
req.session._msg = { type: 'danger', text: 'Gagal memperbarui NAS: ' + error.message };
}
res.redirect('/admin/radius-settings');
});
router.post('/radius/nas/toggle', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const { id } = req.body;
const nas = db.prepare(`SELECT id, nasname, is_active FROM radius_nas WHERE id = ?`).get(id);
if (nas) {
const newStatus = nas.is_active ? 0 : 1;
db.prepare(`UPDATE radius_nas SET is_active = ?, updated_at = (NOW_LOCAL()) WHERE id = ?`).run(newStatus, id);
req.session._msg = { type: 'success', text: `Status NAS ${nas.nasname} diubah menjadi ${newStatus ? 'Aktif' : 'Non-Aktif'}.` };
}
} catch (error) {
logger.error('Error toggling NAS status:', error);
req.session._msg = { type: 'danger', text: 'Gagal mengubah status NAS: ' + error.message };
}
res.redirect('/admin/radius-settings');
});
router.post('/radius/nas/delete', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const { id } = req.body;
db.prepare(`DELETE FROM radius_nas WHERE id = ?`).run(id);
req.session._msg = { type: 'success', text: 'NAS Client berhasil dihapus.' };
} catch (error) {
logger.error('Error deleting NAS:', error);
req.session._msg = { type: 'danger', text: 'Gagal menghapus NAS: ' + error.message };
}
res.redirect('/admin/radius-settings');
});
module.exports = router;