OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana

Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor,
admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS.

Kredit & lisensi (ISC):
- Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak
  atas base kode dan repo yang bermanfaat.
- Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder.
- Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
This commit is contained in:
oxpoe committed 2026-10-09 05:51:21 +07:00
commit eaf05f2811
280 files changed
+123975

No files matched your search

+1923
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+519
View File
@@ -0,0 +1,519 @@
const express = require('express');
const router = express.Router();
const { getSetting, getCurrentDateInTimezone, getSettings, formatDateLocal, getNowLocal, formatTimeLocal, parseDateInTimezone } = require('../config/settingsManager');
const { logger } = require('../config/logger');
const db = require('../config/database');
const billingSvc = require('../services/billingService');
const customerSvc = require('../services/customerService');
const adminSvc = require('../services/adminService');
const attendanceSvc = require('../services/attendanceService');
const pdfSvc = require('../services/pdfInvoiceService');
const whatsappService = require('../services/whatsappService');
const { uploadAttendance, removeAttendanceFile } = require('../middleware/attendanceUpload');
function requireCollectorSession(req, res, next) {
if (req.session && req.session.isCollector && req.session.collectorId) return next();
return res.redirect('/collector/login');
}
function company() {
return getSetting('company_header', 'ISP App');
}
function flashMsg(req) {
const m = req.session._msg;
delete req.session._msg;
return m || null;
}
router.use((req, res, next) => {
res.locals.session = req.session;
res.locals.settings = getSettings();
res.locals.formatDateLocal = formatDateLocal;
res.locals.formatTimeLocal = formatTimeLocal;
res.locals.parseDateInTimezone = parseDateInTimezone;
res.locals.getNowLocal = getNowLocal;
next();
});
let loginRateLimiter = (req, res, next) => next();
try {
const rlMod = require('../middleware/rateLimiter');
if (rlMod && typeof rlMod.loginRateLimiter === 'function') {
loginRateLimiter = rlMod.loginRateLimiter;
}
} catch (e) {}
router.get('/login', (req, res) => {
if (req.session && req.session.isCollector) return res.redirect('/collector');
res.render('collector/login', { title: 'Login Kolektor', company: company(), error: null });
});
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => {
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
const collector = adminSvc.authenticateCollector(username, password);
if (collector) {
req.session.isCollector = true;
req.session.collectorId = collector.id;
req.session.collectorName = collector.name;
req.session.collectorUsername = collector.username;
req.session.collectorArea = collector.area || '';
return res.redirect('/collector');
}
return res.render('collector/login', { title: 'Login Kolektor', company: company(), error: 'Username atau password salah!' });
});
router.get('/logout', (req, res) => {
req.session.destroy();
res.redirect('/collector/login');
});
// ─── COLLECTOR ATTENDANCE ────────────────────────────────────────────────────
router.get('/attendance', requireCollectorSession, (req, res) => {
try {
const collectorId = req.session.collectorId;
const collectorName = req.session.collectorName;
const todayAttendance = attendanceSvc.getTodayAttendance('collector', collectorId);
const history = attendanceSvc.getAttendanceHistory('collector', collectorId, 10);
const now = getCurrentDateInTimezone();
const summary = attendanceSvc.getMonthlyAttendanceSummary(
'collector',
collectorId,
now.getFullYear(),
now.getMonth() + 1
);
res.render('collector/attendance', {
title: 'Absensi',
company: company(),
activePage: 'attendance',
collectorName,
todayAttendance,
history,
summary,
msg: flashMsg(req)
});
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal memuat absensi: ' + e.message };
res.redirect('/collector');
}
});
router.post('/attendance/checkin', requireCollectorSession, uploadAttendance.single('photo'), (req, res) => {
try {
const collectorId = req.session.collectorId;
const collectorName = req.session.collectorName;
if (!req.file) {
return res.json({ success: false, message: 'Foto check-in wajib diunggah' });
}
const today = attendanceSvc.getTodayAttendance('collector', collectorId);
if (today) {
removeAttendanceFile(req.file);
return res.json({ success: false, message: 'Anda sudah melakukan check-in hari ini' });
}
const result = attendanceSvc.checkIn({
employee_type: 'collector',
employee_id: collectorId,
employee_name: collectorName,
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('/attendance/checkout', requireCollectorSession, uploadAttendance.single('photo'), (req, res) => {
try {
const collectorId = req.session.collectorId;
if (!req.file) {
return res.json({ success: false, message: 'Foto check-out wajib diunggah' });
}
const today = attendanceSvc.getTodayAttendance('collector', collectorId);
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('/', requireCollectorSession, (req, res) => {
const now = new Date();
const month = Math.max(1, Math.min(12, parseInt(req.query.month || (now.getMonth() + 1), 10) || (now.getMonth() + 1)));
const year = parseInt(req.query.year || now.getFullYear(), 10) || now.getFullYear();
const status = String(req.query.status || 'all').trim(); // all, unpaid, paid
const search = String(req.query.search || '').trim();
const scope = String(req.query.scope || '').trim(); // today, unpaid, isolir, multi, all
const todayDay = now.getDate();
const collectorId = Number(req.session.collectorId || 0);
const collectorObj = db.prepare('SELECT area FROM collectors WHERE id = ?').get(collectorId);
const collectorArea = String(collectorObj?.area || req.session.collectorArea || '').trim();
req.session.collectorArea = collectorArea;
let collectorWhere = '(c.collector_id = ? OR c.collector_id IS NULL)';
let collectorParams = [collectorId];
if (collectorArea) {
collectorWhere = '(c.collector_id = ? OR ((c.collector_id IS NULL OR c.collector_id = 0) AND LOWER(TRIM(c.area)) = LOWER(TRIM(?))))';
collectorParams = [collectorId, collectorArea];
}
let q = `
SELECT c.id as customer_id,
c.name as customer_name,
c.phone as customer_phone,
c.address as customer_address,
c.area as customer_area,
c.pppoe_username,
c.genieacs_tag,
c.connection_type,
c.static_ip,
c.status as customer_status,
c.install_date,
c.isolate_day,
c.lat, c.lng,
c.collector_id,
p.name as package_name,
p.price as package_price,
r.name as router_name,
i.id as invoice_id,
i.status as invoice_status,
i.amount as invoice_amount,
i.period_month,
i.period_year
FROM customers c
LEFT JOIN packages p ON c.package_id = p.id
LEFT JOIN routers r ON c.router_id = r.id
LEFT JOIN invoices i ON i.customer_id = c.id AND i.period_month = ? AND i.period_year = ?
WHERE ${collectorWhere}
`;
const params = [month, year, ...collectorParams];
if (scope === 'today') {
q += ' AND c.isolate_day = ?';
params.push(todayDay);
} else if (scope === 'isolir') {
q += " AND c.status = 'suspended'";
} else if (scope === 'unpaid') {
q += " AND (i.status = 'unpaid' OR i.status IS NULL)";
} else if (scope === 'paid') {
q += " AND i.status = 'paid'";
} else if (scope === 'multi') {
q += `
AND c.id IN (
SELECT customer_id FROM invoices
WHERE status='unpaid'
GROUP BY customer_id
HAVING COUNT(1) > 1
)
`;
}
if (status === 'unpaid') {
q += " AND (i.status = 'unpaid' OR i.status IS NULL)";
} else if (status === 'paid') {
q += " AND i.status = 'paid'";
}
if (search) {
q += ' AND (c.name LIKE ? OR c.phone LIKE ? OR c.area LIKE ? OR c.genieacs_tag LIKE ? OR c.pppoe_username LIKE ? OR c.customer_code LIKE ?)';
const like = `%${search}%`;
params.push(like, like, like, like, like, like);
}
q += ' ORDER BY c.name ASC, c.id DESC LIMIT 500';
const list = db.prepare(q).all(...params);
const summaryPeriod = db.prepare(`
SELECT
COUNT(DISTINCT c.id) as total_customer_count,
SUM(CASE WHEN (i.status='unpaid' OR i.status IS NULL) THEN 1 ELSE 0 END) as unpaid_count,
SUM(CASE WHEN (i.status='unpaid' OR i.status IS NULL) THEN COALESCE(i.amount, p.price, 0) ELSE 0 END) as unpaid_total,
SUM(CASE WHEN (i.status='unpaid' OR i.status IS NULL) AND c.isolate_day=? THEN 1 ELSE 0 END) as today_count,
SUM(CASE WHEN (i.status='unpaid' OR i.status IS NULL) AND c.isolate_day=? THEN COALESCE(i.amount, p.price, 0) ELSE 0 END) as today_total,
SUM(CASE WHEN c.status='suspended' THEN 1 ELSE 0 END) as isolir_count,
SUM(CASE WHEN c.status='suspended' THEN COALESCE(i.amount, p.price, 0) ELSE 0 END) as isolir_total
FROM customers c
LEFT JOIN packages p ON c.package_id = p.id
LEFT JOIN invoices i ON i.customer_id = c.id AND i.period_month=? AND i.period_year=?
WHERE ${collectorWhere}
`).get(todayDay, todayDay, month, year, ...collectorParams) || {};
const summaryMulti = db.prepare(`
SELECT
COUNT(1) as multi_customer_count,
SUM(x.cnt) as multi_invoice_count,
SUM(x.total_amount) as multi_total
FROM (
SELECT i.customer_id, COUNT(1) as cnt, SUM(i.amount) as total_amount
FROM invoices i
JOIN customers c ON i.customer_id = c.id
WHERE i.status='unpaid'
AND ${collectorWhere}
GROUP BY i.customer_id
HAVING COUNT(1) > 1
) x
`).get(...collectorParams) || {};
const summary = { ...summaryPeriod, ...summaryMulti };
const customerIds = list.map(c => Number(c?.customer_id || 0)).filter(n => Number.isFinite(n) && n > 0);
const pendingMap = new Map();
if (customerIds.length > 0) {
const placeholders = customerIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT r.*
FROM collector_payment_requests r
WHERE r.customer_id IN (${placeholders})
ORDER BY r.id DESC
`).all(...customerIds);
for (const r of rows) {
const cid = Number(r.customer_id || 0);
const invId = Number(r.invoice_id || 0);
if (invId > 0 && !pendingMap.has('inv_' + invId)) pendingMap.set('inv_' + invId, r);
if (cid > 0 && !pendingMap.has('cust_' + cid)) pendingMap.set('cust_' + cid, r);
}
}
const myReqs = db.prepare(`
SELECT r.*, i.period_month, i.period_year, i.amount as invoice_amount, c.name as customer_name, c.phone as customer_phone
FROM collector_payment_requests r
JOIN invoices i ON i.id = r.invoice_id
JOIN customers c ON c.id = r.customer_id
WHERE r.collector_id = ?
ORDER BY r.id DESC
LIMIT 60
`).all(collectorId);
res.render('collector/dashboard', {
title: 'Dashboard Kolektor',
company: company(),
month,
year,
status,
search,
scope,
todayDay,
summary,
invoices: list,
pendingMap,
myReqs,
msg: flashMsg(req)
});
});
router.post('/payment-request', requireCollectorSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
let invoiceId = Number(req.body.invoice_id || 0);
const customerId = Number(req.body.customer_id || 0);
const m = Number(req.body.month || (new Date().getMonth() + 1));
const y = Number(req.body.year || new Date().getFullYear());
const note = String(req.body.note || '').trim();
// If invoice doesn't exist yet, auto generate invoice on-the-fly!
if ((!invoiceId || invoiceId <= 0) && customerId > 0) {
const genResult = billingSvc.generateInvoiceForCustomer(customerId, m, y);
invoiceId = Number(genResult.invoiceId || 0);
}
if (!Number.isFinite(invoiceId) || invoiceId <= 0) throw new Error('Tagihan / Invoice tidak valid');
const inv = billingSvc.getInvoiceById(invoiceId);
if (!inv) throw new Error('Tagihan tidak ditemukan');
if (String(inv.status || '').toLowerCase() === 'paid') throw new Error('Tagihan sudah lunas');
const existingPending = db.prepare(`
SELECT id FROM collector_payment_requests
WHERE invoice_id = ? AND status = 'pending'
ORDER BY id DESC LIMIT 1
`).get(invoiceId);
if (existingPending) throw new Error('Tagihan ini sudah pernah diajukan dan masih menunggu approval');
const collectorId = Number(req.session.collectorId || 0);
const amount = Math.max(0, Number(inv.amount || 0) || 0);
if (amount <= 0) throw new Error('Nominal tagihan tidak valid');
// Check if auto-approve is enabled for this collector
const collector = db.prepare('SELECT auto_approve FROM collectors WHERE id = ?').get(collectorId);
const autoApproveEnabled = collector && collector.auto_approve === 1;
if (autoApproveEnabled) {
// Auto-approve: directly mark invoice as paid
const collectorName = String(req.session.collectorName || '').trim();
const collectorUsername = String(req.session.collectorUsername || '').trim();
const collectorLabel = `Kolektor ${collectorName}${collectorUsername ? ` (@${collectorUsername})` : ''}`;
const notesParts = [
'Via Kolektor',
collectorLabel,
'Auto-Approved (Kolektor Setting Aktif)'
];
if (note) notesParts.push(note);
const notes = notesParts.join(' | ');
// Mark invoice as paid
billingSvc.markAsPaid(invoiceId, collectorLabel, notes);
// Insert request with approved status
db.prepare(`
INSERT INTO collector_payment_requests (collector_id, invoice_id, customer_id, amount, note, status, decided_by_role, decided_by_name, decided_note, decided_at)
VALUES (?, ?, ?, ?, ?, 'approved', 'system', 'Auto-Approve', 'Otomatis disetujui (kolektor setting aktif)', (NOW_LOCAL()))
`).run(collectorId, invoiceId, Number(inv.customer_id || 0), amount, note);
// Auto-unisolate if customer status is currently suspended and NO MORE unpaid invoices remain
const customer = customerSvc.getCustomerById(inv.customer_id);
let unisolatedText = '';
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) {
try {
await customerSvc.activateCustomer(customer.id);
unisolatedText = ' dan layanan pelanggan di-unisolate';
logger.info(`[Collector Auto-Approve] Customer ${customer.id} (${customer.name}) auto-unisolated on payment.`);
} catch (actErr) {
logger.error(`[Collector Auto-Approve] Failed to auto-activate customer ${customer.id}:`, actErr);
}
} else {
logger.info(`[Collector Auto-Approve] Customer ${customer.id} (${customer.name}) remains suspended because ${unpaidCount} unpaid invoice(s) still exist.`);
}
}
// Send WhatsApp notification to customer
if (customer && customer.phone) {
const allSettings = getSettings();
const appUrl = (allSettings.public_base_url || '').replace(/\/$/, '');
const portalUrl = appUrl ? `${appUrl}/customer` : '';
const template = db.getAppSetting('whatsapp_payment_success_message', '');
const msg = whatsappService.formatPaymentSuccessMessage({
customerName: customer.name,
invoiceId: inv.id,
customerUsername: customer.pppoe_username || customer.id || '-',
customerCode: customer.customer_code || '',
packageName: customer.package_name || '-',
periodMonth: inv.period_month,
periodYear: inv.period_year,
amount: inv.amount,
paymentMethod: collectorLabel || 'Kolektor / Lapangan',
paidAt: new Date(),
companyName: allSettings.company_header || 'OPEN-ISP',
companyPhone: allSettings.company_phone || '',
portalUrl,
customTemplate: template
});
try {
const waNotify = require('../services/waNotifyService');
if (true) {
await waNotify.sendText(customer.phone, msg);
}
} catch (e) {
logger.error('Failed to send WA notification for auto-approved collector payment:', e);
}
}
req.session._msg = { type: 'success', text: `Pembayaran berhasil diproses, tagihan lunas${unisolatedText}. <a href="/collector/invoice/${invoiceId}/print-thermal" target="_blank" class="btn btn-sm btn-dark ms-2 fw-bold"><i class="bi bi-printer"></i> Cetak Struk (Bluetooth Thermal)</a>` };
} else {
// Manual approval: insert as pending
db.prepare(`
INSERT INTO collector_payment_requests (collector_id, invoice_id, customer_id, amount, note, status)
VALUES (?, ?, ?, ?, ?, 'pending')
`).run(collectorId, invoiceId, Number(inv.customer_id || 0), amount, note);
req.session._msg = { type: 'success', text: 'Berhasil. Status pembayaran menunggu approval Admin/Kasir.' };
}
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + (e.message || String(e)) };
}
const qs = new URLSearchParams();
if (req.body.month) qs.set('month', String(req.body.month));
if (req.body.year) qs.set('year', String(req.body.year));
if (req.body.status) qs.set('status', String(req.body.status));
if (req.body.search) qs.set('search', String(req.body.search));
const suffix = qs.toString() ? ('?' + qs.toString()) : '';
res.redirect('/collector' + suffix);
});
// ─── THERMAL RECEIPT PRINT ROUTE (58mm/80mm Bluetooth Printer) ───────────────
router.get('/invoice/:id/print-thermal', requireCollectorSession, (req, res) => {
try {
const invoiceId = Number(req.params.id || 0);
const invoice = billingSvc.getInvoiceById(invoiceId);
if (!invoice) return res.status(404).send('Invoice tidak ditemukan');
const customer = customerSvc.getCustomerById(invoice.customer_id);
if (!customer) return res.status(404).send('Data pelanggan tidak ditemukan');
const settings = getSettings();
res.render('collector/print_thermal', {
invoice,
customer,
settings,
company: company(),
collectorName: req.session.collectorName || 'Kolektor',
formatDateLocal,
formatTimeLocal,
getNowLocal
});
} catch (e) {
res.status(500).send('Error: ' + e.message);
}
});
// ─── SEND INVOICE PDF VIA WHATSAPP ROUTE ──────────────────────────────────────
router.post('/invoice/:id/send-pdf-wa', requireCollectorSession, async (req, res) => {
try {
const invoiceId = Number(req.params.id || 0);
const inv = billingSvc.getInvoiceById(invoiceId);
if (!inv) return res.json({ success: false, message: 'Invoice tidak ditemukan' });
const customer = customerSvc.getCustomerById(inv.customer_id);
if (!customer || !customer.phone) return res.json({ success: false, message: 'Nomor HP pelanggan tidak valid' });
const settings = getSettings();
const pdfBuffer = await pdfSvc.generateInvoicePdfBuffer(inv, customer, settings);
const filename = `Invoice_INV-${String(inv.id).padStart(4, '0')}.pdf`;
const caption = `🧾 *FAKTUR / INVOICE LUNAS*\n\nYth. Bpk/Ibu *${customer.name}*,\nBerikut kami lampirkan dokumen Invoice LUNAS periode ${inv.period_month}/${inv.period_year}.\n\nTerima kasih atas pembayaran Anda.`;
const waBot = require('../services/whatsappBot.mjs');
const result = await waBot.waNotify.sendDocument(customer.phone, pdfBuffer, filename, caption);
if (result && result.success) {
return res.json({ success: true, message: 'Dokumen Invoice PDF berhasil dikirim via WhatsApp ke pelanggan!' });
} else {
return res.json({ success: false, message: result?.message || 'Gagal mengirim WA PDF' });
}
} catch (e) {
return res.json({ success: false, message: 'Error: ' + e.message });
}
});
module.exports = router;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+152
View File
@@ -0,0 +1,152 @@
const express = require('express');
const router = express.Router();
const db = require('../config/database');
const { getSetting, getSettings, getNowLocal, formatDateLocal } = require('../config/settingsManager');
const sidebarMenuSvc = require('../services/sidebarMenuService');
// Helper untuk menampilkan notifikasi sukses/error
function flashMsg(req) {
const m = req.session._msg;
delete req.session._msg;
return m || null;
}
function company() { return getSetting('company_header', 'ISP Admin'); }
// Pastikan hanya admin & kasir yang bisa akses
function requireAdminSession(req, res, next) {
if (req.session?.isAdmin || req.session?.isCashier) return next();
return res.redirect('/admin/login');
}
router.use(requireAdminSession);
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.lang = req.session?.lang || 'id';
res.locals.getNowLocal = getNowLocal;
res.locals.formatDateLocal = formatDateLocal;
next();
});
// ─── KATEGORI PENGELUARAN ──────────────────────────────────────────────
router.get('/expense-categories', (req, res) => {
const categories = db.prepare('SELECT * FROM expense_categories ORDER BY name ASC').all();
res.render('admin/finance/expense_categories', {
activePage: 'expense_categories',
categories,
msg: flashMsg(req)
});
});
router.post('/expense-categories', express.urlencoded({ extended: true }), (req, res) => {
try {
const { name, description, icon, color } = req.body;
db.prepare(`INSERT INTO expense_categories (name, description, icon, color) VALUES (?, ?, ?, ?)`).run(name, description, icon || 'bi bi-tag', color || '#6366f1');
req.session._msg = { type: 'success', text: 'Kategori berhasil ditambahkan' };
} catch(e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/finance/expense-categories');
});
router.post('/expense-categories/:id/delete', (req, res) => {
try {
db.prepare('DELETE FROM expense_categories WHERE id = ?').run(req.params.id);
req.session._msg = { type: 'success', text: 'Kategori berhasil dihapus' };
} catch(e) {
req.session._msg = { type: 'error', text: 'Gagal dihapus (mungkin sedang digunakan)' };
}
res.redirect('/admin/finance/expense-categories');
});
// ─── PENGELUARAN (CASH OUT) ────────────────────────────────────────────
router.get('/expenses', (req, res) => {
const expenses = db.prepare(`
SELECT e.*, c.color as category_color, c.icon as category_icon
FROM expenses e
LEFT JOIN expense_categories c ON c.name = e.category
ORDER BY e.date DESC, e.id DESC LIMIT 500
`).all();
const categories = db.prepare('SELECT * FROM expense_categories ORDER BY name ASC').all();
res.render('admin/finance/expenses', {
activePage: 'expenses',
expenses,
categories,
msg: flashMsg(req)
});
});
router.post('/expenses', express.urlencoded({ extended: true }), (req, res) => {
try {
const { date, category, amount, description, payment_method, receipt_number, vendor } = req.body;
const cleanAmount = String(amount).replace(/[^0-9]/g, ''); // bersihkan format rupiah
const recorded_by_name = req.session.cashierName ? `Kasir ${req.session.cashierName}` : 'Admin';
db.prepare(`
INSERT INTO expenses (date, category, amount, description, payment_method, receipt_number, vendor, recorded_by_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(date, category, cleanAmount, description, payment_method, receipt_number, vendor, recorded_by_name);
req.session._msg = { type: 'success', text: 'Pengeluaran berhasil dicatat' };
} catch(e) {
req.session._msg = { type: 'error', text: 'Gagal mencatat pengeluaran: ' + e.message };
}
res.redirect('/admin/finance/expenses');
});
router.post('/expenses/:id/delete', (req, res) => {
try {
db.prepare('DELETE FROM expenses WHERE id = ?').run(req.params.id);
req.session._msg = { type: 'success', text: 'Data berhasil dihapus' };
} catch(e) {
req.session._msg = { type: 'error', text: 'Gagal dihapus' };
}
res.redirect('/admin/finance/expenses');
});
// ─── KAS MASUK (CASH IN) ───────────────────────────────────────────────
router.get('/cash-in', (req, res) => {
const cashIn = db.prepare(`SELECT * FROM cash_in ORDER BY date DESC, id DESC LIMIT 500`).all();
res.render('admin/finance/cash_in', {
activePage: 'cash_in',
cashIn,
msg: flashMsg(req)
});
});
router.post('/cash-in', express.urlencoded({ extended: true }), (req, res) => {
try {
const { date, category, amount, description, payment_method, receipt_number } = req.body;
const cleanAmount = String(amount).replace(/[^0-9]/g, ''); // bersihkan format rupiah
const recorded_by_name = req.session.cashierName ? `Kasir ${req.session.cashierName}` : 'Admin';
db.prepare(`
INSERT INTO cash_in (date, category, amount, description, payment_method, receipt_number, recorded_by_name)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(date, category, cleanAmount, description, payment_method, receipt_number, recorded_by_name);
req.session._msg = { type: 'success', text: 'Kas Masuk berhasil dicatat' };
} catch(e) {
req.session._msg = { type: 'error', text: 'Gagal mencatat Kas Masuk: ' + e.message };
}
res.redirect('/admin/finance/cash-in');
});
router.post('/cash-in/:id/delete', (req, res) => {
try {
db.prepare('DELETE FROM cash_in WHERE id = ?').run(req.params.id);
req.session._msg = { type: 'success', text: 'Data berhasil dihapus' };
} catch(e) {
req.session._msg = { type: 'error', text: 'Gagal dihapus' };
}
res.redirect('/admin/finance/cash-in');
});
module.exports = router;
+350
View File
@@ -0,0 +1,350 @@
/**
* Settings API Routes
* Untuk manage settings lewat API dengan security & validation
*/
const express = require('express');
const router = express.Router();
const { logger } = require('../config/logger');
const { getSetting, getSettings, saveSettings } = require('../config/settingsManager');
const { getMaskedSettings } = require('../config/settingsEncryption');
const { getAllRules } = require('../config/settingsValidator');
const { getChangeHistory, getAuditStats, exportAuditLog } = require('../config/settingsAudit');
const {
requireSuperAdmin,
validateSettingsMiddleware,
maskSensitiveValues,
logSettingsAccess,
rateLimitSettings
} = require('../middleware/settingsMiddleware');
// Apply middleware
router.use(rateLimitSettings);
router.use(maskSensitiveValues);
router.use(logSettingsAccess);
/**
* GET /api/settings
* Get all settings (masked)
*/
router.get('/', requireSuperAdmin, (req, res) => {
try {
const settings = getSettings();
const masked = getMaskedSettings(settings);
return res.json({
success: true,
settings: masked
});
} catch (error) {
logger.error(`[settings-api] Error getting settings: ${error.message}`);
return res.status(500).json({
error: 'Internal server error'
});
}
});
/**
* GET /api/settings/rules
* Get validation rules untuk setiap field
*/
router.get('/rules', requireSuperAdmin, (req, res) => {
try {
const rules = getAllRules();
return res.json({
success: true,
rules
});
} catch (error) {
logger.error(`[settings-api] Error getting rules: ${error.message}`);
return res.status(500).json({
error: 'Internal server error'
});
}
});
/**
* GET /api/settings/:field
* Get single setting (masked)
*/
router.get('/:field', requireSuperAdmin, (req, res) => {
try {
const { field } = req.params;
const value = getSetting(field);
if (value === null || value === undefined) {
return res.status(404).json({
error: 'Setting not found'
});
}
const { getMaskedSettings } = require('../config/settingsEncryption');
const masked = getMaskedSettings({ [field]: value });
return res.json({
success: true,
field,
value: masked[field]
});
} catch (error) {
logger.error(`[settings-api] Error getting setting: ${error.message}`);
return res.status(500).json({
error: 'Internal server error'
});
}
});
/**
* POST /api/settings
* Update settings dengan validation & encryption
*/
router.post('/', requireSuperAdmin, validateSettingsMiddleware, (req, res) => {
try {
const settings = req.validatedSettings;
const actor = req.session?.username || 'unknown';
const metadata = {
ip: req.ip,
userAgent: req.get('user-agent')
};
const result = saveSettings(settings, actor, metadata);
if (!result.success) {
return res.status(400).json({
error: 'Failed to save settings',
errors: result.errors
});
}
return res.json({
success: true,
message: 'Settings saved successfully',
changes: result.changes
});
} catch (error) {
logger.error(`[settings-api] Error saving settings: ${error.message}`);
return res.status(500).json({
error: 'Internal server error'
});
}
});
/**
* PUT /api/settings/:field
* Update single setting
*/
router.put('/:field', requireSuperAdmin, (req, res) => {
try {
const { field } = req.params;
const { value } = req.body;
if (value === undefined) {
return res.status(400).json({
error: 'Value is required'
});
}
const settings = { [field]: value };
const actor = req.session?.username || 'unknown';
const metadata = {
ip: req.ip,
userAgent: req.get('user-agent')
};
const result = saveSettings(settings, actor, metadata);
if (!result.success) {
return res.status(400).json({
error: 'Failed to save setting',
errors: result.errors
});
}
return res.json({
success: true,
message: `Setting ${field} updated successfully`
});
} catch (error) {
logger.error(`[settings-api] Error updating setting: ${error.message}`);
return res.status(500).json({
error: 'Internal server error'
});
}
});
/**
* GET /api/settings/audit/history
* Get settings change history
*/
router.get('/audit/history', requireSuperAdmin, (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 100, 1000);
const history = getChangeHistory(limit);
return res.json({
success: true,
count: history.length,
history
});
} catch (error) {
logger.error(`[settings-api] Error getting history: ${error.message}`);
return res.status(500).json({
error: 'Internal server error'
});
}
});
/**
* GET /api/settings/audit/stats
* Get audit statistics
*/
router.get('/audit/stats', requireSuperAdmin, (req, res) => {
try {
const stats = getAuditStats();
return res.json({
success: true,
stats
});
} catch (error) {
logger.error(`[settings-api] Error getting stats: ${error.message}`);
return res.status(500).json({
error: 'Internal server error'
});
}
});
/**
* GET /api/settings/audit/export
* Export audit log
*/
router.get('/audit/export', requireSuperAdmin, (req, res) => {
try {
const format = req.query.format || 'json';
const result = exportAuditLog(format);
if (!result) {
return res.status(500).json({
error: 'Failed to export audit log'
});
}
if (format === 'csv') {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="settings-audit.csv"');
return res.send(result.data);
}
return res.json({
success: true,
format: 'json',
count: result.count,
data: result.data
});
} catch (error) {
logger.error(`[settings-api] Error exporting audit: ${error.message}`);
return res.status(500).json({
error: 'Internal server error'
});
}
});
/**
* POST /api/settings/test-connection/:service
* Test connection ke external service
*/
router.post('/test-connection/:service', requireSuperAdmin, async (req, res) => {
try {
const { service } = req.params;
let result = { success: false, message: '' };
switch (service) {
case 'genieacs':
result = await testGenieACSConnection();
break;
case 'mikrotik':
result = await testMikroTikConnection();
break;
default:
return res.status(400).json({
error: 'Unknown service'
});
}
return res.json(result);
} catch (error) {
logger.error(`[settings-api] Error testing connection: ${error.message}`);
return res.status(500).json({
error: 'Internal server error'
});
}
});
/**
* Test GenieACS connection
*/
async function testGenieACSConnection() {
try {
const axios = require('axios');
const url = getSetting('genieacs_url');
const username = getSetting('genieacs_username');
const password = getSetting('genieacs_password');
if (!url || !username || !password) {
return { success: false, message: 'GenieACS credentials not configured' };
}
const response = await axios.get(`${url}/devices`, {
auth: { username, password },
timeout: 5000
});
return { success: true, message: 'GenieACS connection successful' };
} catch (error) {
return { success: false, message: `GenieACS connection failed: ${error.message}` };
}
}
/**
* Test MikroTik connection
*/
async function testMikroTikConnection() {
try {
const { RouterOSClient } = require('routeros-client');
const host = getSetting('mikrotik_host');
const user = getSetting('mikrotik_user');
const password = getSetting('mikrotik_password');
const port = getSetting('mikrotik_port') || 8728;
if (!host || !user || !password) {
return { success: false, message: 'MikroTik credentials not configured' };
}
const useTls = Number(port) === 8729 || getSetting('mikrotik_tls') === true;
const api = new RouterOSClient({
host,
port: Number(port),
user,
password,
tls: useTls ? { rejectUnauthorized: false } : false, // dukung self-signed cert (8729)
timeout: 5000
});
const client = await api.connect();
await client.close();
return { success: true, message: 'MikroTik connection successful' };
} catch (error) {
return { success: false, message: `MikroTik connection failed: ${error.message}` };
}
}
/**
* Test Tripay connection
*/
/**
* Test Midtrans connection
*/
module.exports = router;
+72
View File
@@ -0,0 +1,72 @@
/**
* Backend LibreSpeed (kompatibel protokol speedtest) untuk self-hosted speed test.
* - GET /speedtest/garbage.php -> stream data acak (unduh)
* - ALL /speedtest/empty.php -> terima & buang data (unggah / ping)
* - ALL /speedtest/getIP.php -> info IP klien
*
* Dipasang SEBELUM body-parser global agar unggahan besar tidak dibuffer.
*/
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
// Buffer acak 4 MB yang dipakai berulang (hindari alokasi per-chunk)
const RANDOM_CHUNK = crypto.randomBytes(4 * 1024 * 1024);
const MAX_MB = 512;
router.get('/garbage.php', (req, res) => {
let mb = parseInt(req.query.ckSize, 10);
if (!Number.isFinite(mb) || mb <= 0) mb = 100;
if (mb > MAX_MB) mb = MAX_MB;
const total = mb * 1024 * 1024;
res.set('Content-Type', 'application/octet-stream');
res.set('Content-Description', 'File Transfer');
res.set('Content-Transfer-Encoding', 'binary');
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
res.set('Pragma', 'no-cache');
let sent = 0;
const pump = () => {
while (sent < total) {
const size = Math.min(RANDOM_CHUNK.length, total - sent);
sent += size;
const chunk = size === RANDOM_CHUNK.length ? RANDOM_CHUNK : RANDOM_CHUNK.subarray(0, size);
if (!res.write(chunk)) {
res.once('drain', pump);
return;
}
}
res.end();
};
pump();
});
router.all('/empty.php', (req, res) => {
// Buang seluruh body (unggahan) tanpa menyimpan
req.on('data', () => {});
req.on('error', () => {});
const finish = () => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
res.status(200).type('text/plain').send('OK');
};
if (req.readableEnded || req.complete) finish();
else req.on('end', finish);
});
router.all('/getIP.php', (req, res) => {
let ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim();
if (!ip) ip = (req.socket && req.socket.remoteAddress) || '';
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
res.json({ processedString: ip, rawIspInfo: '' });
});
// Telemetry (tidak dipakai bila telemetry_level=disabled, tetap disediakan)
router.all('/telemetry.php', (req, res) => {
req.on('data', () => {});
if (req.readableEnded || req.complete) { res.status(200).json({ ok: true }); return; }
req.on('end', () => res.status(200).json({ ok: true }));
});
module.exports = router;
+1287
View File
File diff suppressed because it is too large. Load diff
+250
View File
@@ -0,0 +1,250 @@
/**
* Voucher Payment API Routes
* Clean API untuk voucher payment dengan auto-detect payment gateway
*/
const express = require('express');
const router = express.Router();
const { logger } = require('../config/logger');
const db = require('../config/database');
const voucherPaymentSvc = require('../services/voucherPaymentService');
const { getSettingsWithCache } = require('../config/settingsManager');
/**
* GET /api/voucher/payment-methods
* Get available payment methods dari semua gateway yang aktif
*/
router.get('/payment-methods', async (req, res) => {
try {
const methods = await voucherPaymentSvc.getAvailablePaymentMethods();
if (!methods || methods.length === 0) {
return res.status(503).json({
success: false,
error: 'Tidak ada payment gateway yang aktif'
});
}
// Group by gateway
const grouped = {};
methods.forEach(method => {
if (!grouped[method.gateway]) {
grouped[method.gateway] = [];
}
grouped[method.gateway].push(method);
});
return res.json({
success: true,
methods,
grouped,
count: methods.length
});
} catch (error) {
logger.error('[VoucherPaymentAPI] Error getting payment methods:', error.message);
return res.status(500).json({
success: false,
error: 'Gagal mengambil payment methods'
});
}
});
/**
* POST /api/voucher/create-payment
* Create voucher payment transaction
*/
router.post('/create-payment', async (req, res) => {
try {
const { voucherOrderId, paymentMethod, appUrl } = req.body;
// Validate input
if (!voucherOrderId) {
return res.status(400).json({
success: false,
error: 'Voucher order ID diperlukan'
});
}
if (!paymentMethod || !paymentMethod.gateway || !paymentMethod.code) {
return res.status(400).json({
success: false,
error: 'Payment method tidak valid'
});
}
// Get voucher order
const voucherOrder = db.prepare(
'SELECT * FROM public_voucher_orders WHERE id = ?'
).get(voucherOrderId);
if (!voucherOrder) {
return res.status(404).json({
success: false,
error: 'Voucher order tidak ditemukan'
});
}
if (voucherOrder.status !== 'pending') {
return res.status(400).json({
success: false,
error: `Voucher order sudah ${voucherOrder.status}`
});
}
// Create payment
const result = await voucherPaymentSvc.createVoucherPayment(
voucherOrder,
paymentMethod,
appUrl
);
// Update voucher order dengan payment info
db.prepare(`
UPDATE public_voucher_orders
SET payment_gateway = ?,
payment_order_id = ?,
payment_link = ?,
payment_reference = ?,
payment_method = ?,
payment_payload = ?,
updated_at = (NOW_LOCAL())
WHERE id = ?
`).run(
result.gateway,
result.orderId || '',
result.link || '',
result.reference || '',
paymentMethod.code,
result.payload ? JSON.stringify(result.payload) : null,
voucherOrderId
);
logger.info(`[VoucherPaymentAPI] Payment created for voucher order ${voucherOrderId}`, {
gateway: result.gateway,
method: paymentMethod.code,
amount: voucherOrder.price
});
return res.json({
success: true,
gateway: result.gateway,
paymentMethod: result.paymentMethod,
link: result.link,
reference: result.reference,
orderId: result.orderId
});
} catch (error) {
logger.error('[VoucherPaymentAPI] Error creating payment:', error.message);
return res.status(500).json({
success: false,
error: error.message || 'Gagal membuat payment'
});
}
});
/**
* GET /api/voucher/order/:orderId
* Get voucher order details
*/
router.get('/order/:orderId', (req, res) => {
try {
const { orderId } = req.params;
const order = db.prepare(
'SELECT * FROM public_voucher_orders WHERE id = ?'
).get(orderId);
if (!order) {
return res.status(404).json({
success: false,
error: 'Voucher order tidak ditemukan'
});
}
return res.json({
success: true,
order: {
id: order.id,
profileName: order.profile_name,
price: order.price,
status: order.status,
paymentGateway: order.payment_gateway,
paymentMethod: order.payment_method,
buyerPhone: order.buyer_phone,
createdAt: order.created_at,
paidAt: order.paid_at,
fulfilledAt: order.fulfilled_at,
voucherCode: order.status === 'fulfilled' ? order.voucher_code : null
}
});
} catch (error) {
logger.error('[VoucherPaymentAPI] Error getting order:', error.message);
return res.status(500).json({
success: false,
error: 'Gagal mengambil order details'
});
}
});
/**
* GET /api/voucher/default-gateway
* Get default payment gateway
*/
router.get('/default-gateway', (req, res) => {
try {
const gateway = voucherPaymentSvc.getDefaultPaymentGateway();
if (!gateway) {
return res.status(503).json({
success: false,
error: 'Tidak ada payment gateway yang aktif'
});
}
return res.json({
success: true,
gateway
});
} catch (error) {
logger.error('[VoucherPaymentAPI] Error getting default gateway:', error.message);
return res.status(500).json({
success: false,
error: 'Gagal mengambil default gateway'
});
}
});
/**
* GET /api/voucher/status/:orderId
* Get voucher order status
*/
router.get('/status/:orderId', (req, res) => {
try {
const { orderId } = req.params;
const order = db.prepare(
'SELECT id, status, voucher_code, payment_gateway FROM public_voucher_orders WHERE id = ?'
).get(orderId);
if (!order) {
return res.status(404).json({
success: false,
error: 'Voucher order tidak ditemukan'
});
}
return res.json({
success: true,
status: order.status,
voucherCode: order.status === 'fulfilled' ? order.voucher_code : null,
paymentGateway: order.payment_gateway
});
} catch (error) {
logger.error('[VoucherPaymentAPI] Error getting status:', error.message);
return res.status(500).json({
success: false,
error: 'Gagal mengambil status'
});
}
});
module.exports = router;