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

File diff suppressed because it is too large. Load diff
+112
View File
@@ -0,0 +1,112 @@
const db = require('../config/database');
/**
* TECHNICIANS
*/
function getAllTechnicians() {
return db.prepare('SELECT * FROM technicians ORDER BY created_at DESC').all();
}
function createTechnician(data) {
const stmt = db.prepare('INSERT INTO technicians (username, password, name, phone, area) VALUES (?, ?, ?, ?, ?)');
return stmt.run(data.username, data.password, data.name, data.phone || '', data.area || '');
}
function parseBoolInt(val, defaultVal = 0) {
if (val === undefined || val === null || val === '') return defaultVal;
if (val === true || val === 1 || val === '1' || val === 'true' || val === 'on' || val === 'yes') return 1;
if (val === false || val === 0 || val === '0' || val === 'false' || val === 'off' || val === 'no') return 0;
return Boolean(val) ? 1 : 0;
}
function updateTechnician(id, data) {
const stmt = db.prepare('UPDATE technicians SET username = ?, password = ?, name = ?, phone = ?, area = ?, is_active = ? WHERE id = ?');
return stmt.run(data.username, data.password, data.name, data.phone || '', data.area || '', parseBoolInt(data.is_active, 1), id);
}
function deleteTechnician(id) {
return db.prepare('DELETE FROM technicians WHERE id = ?').run(id);
}
/**
* CASHIERS
*/
function getAllCashiers() {
return db.prepare('SELECT * FROM cashiers ORDER BY created_at DESC').all();
}
function createCashier(data) {
const stmt = db.prepare('INSERT INTO cashiers (username, password, name, phone) VALUES (?, ?, ?, ?)');
return stmt.run(data.username, data.password, data.name, data.phone || '');
}
function updateCashier(id, data) {
const stmt = db.prepare('UPDATE cashiers SET username = ?, password = ?, name = ?, phone = ?, is_active = ? WHERE id = ?');
return stmt.run(data.username, data.password, data.name, data.phone || '', parseBoolInt(data.is_active, 1), id);
}
function deleteCashier(id) {
return db.prepare('DELETE FROM cashiers WHERE id = ?').run(id);
}
function authenticateCashier(username, password) {
return db.prepare('SELECT * FROM cashiers WHERE username = ? AND password = ? AND is_active = 1').get(username, password);
}
function getAllCollectors() {
return db.prepare('SELECT * FROM collectors ORDER BY created_at DESC').all();
}
function createCollector(data) {
return db
.prepare(
'INSERT INTO collectors (username, password, name, phone, area, is_active, auto_approve) VALUES (?, ?, ?, ?, ?, 1, ?)'
)
.run(
String(data.username || '').trim(),
String(data.password || ''),
String(data.name || '').trim(),
String(data.phone || '').trim(),
String(data.area || '').trim(),
parseBoolInt(data.auto_approve, 0)
);
}
function updateCollector(id, data) {
const stmt = db.prepare('UPDATE collectors SET username = ?, password = ?, name = ?, phone = ?, area = ?, is_active = ?, auto_approve = ? WHERE id = ?');
return stmt.run(
String(data.username || '').trim(),
String(data.password || ''),
String(data.name || '').trim(),
String(data.phone || '').trim(),
String(data.area || '').trim(),
parseBoolInt(data.is_active, 1),
parseBoolInt(data.auto_approve, 0),
id
);
}
function deleteCollector(id) {
return db.prepare('DELETE FROM collectors WHERE id = ?').run(id);
}
function authenticateCollector(username, password) {
return db.prepare('SELECT * FROM collectors WHERE username = ? AND password = ? AND is_active = 1').get(username, password);
}
module.exports = {
getAllTechnicians,
createTechnician,
updateTechnician,
deleteTechnician,
getAllCashiers,
createCashier,
updateCashier,
deleteCashier,
authenticateCashier,
getAllCollectors,
createCollector,
updateCollector,
deleteCollector,
authenticateCollector
};
+9
View File
@@ -0,0 +1,9 @@
/**
* Catatan: modul PPOB/pulsa sudah dihapus dari fork ini. Layanan agent (saldo, komisi, voucher) tetap aktif.
* File ini hanya stub agar require lama tidak crash.
*/
const noop = async () => null;
const noop0 = () => null;
module.exports = new Proxy({}, {
get: () => (async () => null)
});
+81
View File
@@ -0,0 +1,81 @@
const db = require('../config/database');
/**
* Get all areas with customer and collector statistics
*/
function getAllAreas() {
const sql = `
SELECT a.*,
(SELECT COUNT(*) FROM customers c WHERE LOWER(TRIM(c.area)) = LOWER(TRIM(a.name))) as customer_count,
(SELECT COUNT(*) FROM collectors col WHERE LOWER(TRIM(col.area)) = LOWER(TRIM(a.name))) as collector_count,
(SELECT COUNT(*) FROM technicians t WHERE LOWER(TRIM(t.area)) = LOWER(TRIM(a.name))) as technician_count
FROM areas a
ORDER BY a.name ASC
`;
return db.prepare(sql).all();
}
/**
* Get single area by ID
*/
function getAreaById(id) {
return db.prepare('SELECT * FROM areas WHERE id = ?').get(id);
}
/**
* Create new area
*/
function createArea(data) {
const name = String(data.name || '').trim();
if (!name) throw new Error('Nama Area / Wilayah tidak boleh kosong');
const existing = db.prepare('SELECT id FROM areas WHERE LOWER(TRIM(name)) = LOWER(TRIM(?))').get(name);
if (existing) throw new Error(`Area "${name}" sudah ada.`);
const stmt = db.prepare('INSERT INTO areas (name, description) VALUES (?, ?)');
return stmt.run(name, String(data.description || '').trim());
}
/**
* Update existing area
*/
function updateArea(id, data) {
const oldArea = getAreaById(id);
if (!oldArea) throw new Error('Data Area tidak ditemukan');
const newName = String(data.name || '').trim();
if (!newName) throw new Error('Nama Area / Wilayah tidak boleh kosong');
const existing = db.prepare('SELECT id FROM areas WHERE LOWER(TRIM(name)) = LOWER(TRIM(?)) AND id != ?').get(newName, id);
if (existing) throw new Error(`Nama Area "${newName}" sudah digunakan oleh area lain.`);
const description = String(data.description || '').trim();
// If area name changed, update references in customers, collectors, technicians
if (oldArea.name !== newName) {
db.prepare('UPDATE customers SET area = ? WHERE LOWER(TRIM(area)) = LOWER(TRIM(?))').run(newName, oldArea.name);
db.prepare('UPDATE collectors SET area = ? WHERE LOWER(TRIM(area)) = LOWER(TRIM(?))').run(newName, oldArea.name);
db.prepare('UPDATE technicians SET area = ? WHERE LOWER(TRIM(area)) = LOWER(TRIM(?))').run(newName, oldArea.name);
}
const stmt = db.prepare('UPDATE areas SET name = ?, description = ? WHERE id = ?');
return stmt.run(newName, description, id);
}
/**
* Delete area
*/
function deleteArea(id) {
const area = getAreaById(id);
if (!area) throw new Error('Data Area tidak ditemukan');
return db.prepare('DELETE FROM areas WHERE id = ?').run(id);
}
module.exports = {
getAllAreas,
getAreaById,
createArea,
updateArea,
deleteArea
};
+82
View File
@@ -0,0 +1,82 @@
'use strict';
/**
* arrearsNoticeService.js
* Notifikasi tunggakan: kirim TAGIHAN + PERINGATAN (menuju nonaktif/hapus).
* Template editable di Admin → WhatsApp → Template Pesan:
* - whatsapp_auto_billing_message (tagihan/pengingat)
* - whatsapp_arrears_warning_message (peringatan tunggakan/menuju nonaktif)
*/
const db = require('../config/database');
const { getSetting } = require('../config/settingsManager');
const billingSvc = require('./billingService');
function portalLink() {
let base = String(getSetting('public_base_url', '') || '').trim();
if (!base) {
const h = String(getSetting('server_host', '') || '').trim();
if (h) base = (/^https?:\/\//i.test(h) ? h : 'http://' + h);
}
return (base ? base.replace(/\/+$/, '') : '') + '/customer';
}
function spintax(text) {
return String(text || '').replace(/\{([^{}|]+(?:\|[^{}|]+)+)\}/g, function (m, choices) {
const arr = choices.split('|');
return arr[Math.floor(Math.random() * arr.length)].trim();
});
}
function info(customer) {
let invs = [];
try { invs = billingSvc.getUnpaidInvoicesByCustomerId(customer.id) || []; } catch (e) {}
const inv = invs.length ? invs[invs.length - 1] : null;
let pkgName = inv && inv.package_name ? inv.package_name : '';
if (!pkgName && customer.package_id) {
try { const p = db.prepare('SELECT name FROM packages WHERE id=?').get(customer.package_id); if (p) pkgName = p.name; } catch (e) {}
}
return {
inv,
amount: inv ? Number(inv.amount || 0) : 0,
period: inv ? (inv.period_month + '/' + inv.period_year) : '',
pkgName: pkgName || '-',
streak: Number(customer.unpaid_streak || 0),
batas: Number(getSetting('auto_deactivate_months', 3) || 3),
};
}
function applyVars(tpl, customer, d) {
return String(tpl || '')
.replace(/{{id_pelanggan}}/gi, customer.customer_code || ('ID:' + customer.id))
.replace(/{{nama}}/gi, customer.name || 'Pelanggan')
.replace(/{{paket}}/gi, d.pkgName)
.replace(/{{tagihan}}/gi, d.amount.toLocaleString('id-ID'))
.replace(/{{rincian}}/gi, d.period)
.replace(/{{periode}}/gi, d.period)
.replace(/{{bulan_nunggak}}/gi, String(d.streak))
.replace(/{{batas}}/gi, String(d.batas))
.replace(/{{link}}/gi, portalLink())
.replace(/{{link_portal}}/gi, portalLink())
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-');
}
function buildArrearsMessage(customer) {
const d = info(customer);
const def = "*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 tpl = db.getAppSetting('whatsapp_arrears_warning_message', def) || def;
return spintax(applyVars(tpl, customer, d));
}
function buildBillMessage(customer) {
const d = info(customer);
const def = `Yth. {{nama}},\n\nBerikut tagihan internet Anda:\n\n📦 Paket: {{paket}}\n💰 Total Tagihan: Rp {{tagihan}}\n📅 Periode: {{rincian}}\n\nSilakan lakukan pembayaran melalui portal: {{link}}\n\nTerima kasih.\n— {{company}}`;
const tpl = db.getAppSetting('whatsapp_auto_billing_message', def) || def;
return spintax(applyVars(tpl, customer, d));
}
async function sendTo(phone, text) {
const waNotify = require('./waNotifyService');
return waNotify.sendText(phone, text);
}
module.exports = { buildArrearsMessage, buildBillMessage, sendTo, info, portalLink, spintax };
+318
View File
@@ -0,0 +1,318 @@
const db = require('../config/database');
const { getSetting, getCurrentDateInTimezone, parseDateInTimezone } = require('../config/settingsManager');
/**
* ATTENDANCE SERVICE
* Mengelola absensi karyawan (teknisi, admin, cashier, collector)
*/
// Calculate distance between two coordinates (Haversine formula)
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371; // Radius of Earth in kilometers
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a =
Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
const distance = R * c * 1000; // Convert to meters
return distance;
}
// Validate if location is within allowed radius
function validateLocation(lat, lng) {
// Get office location from settings
const officeLat = parseFloat(getSetting('office_lat', '0'));
const officeLng = parseFloat(getSetting('office_lng', '0'));
const allowedRadius = parseInt(getSetting('attendance_radius', '100')); // Default 100 meters
const geofencingEnabled = getSetting('attendance_geofencing', 'true') === 'true';
// If geofencing disabled or office location not set, allow all
if (!geofencingEnabled || !officeLat || !officeLng) {
return { valid: true, distance: 0, message: 'Geofencing disabled' };
}
// If no GPS provided
if (!lat || !lng) {
return { valid: false, distance: 0, message: 'GPS location required' };
}
// Calculate distance
const distance = calculateDistance(officeLat, officeLng, parseFloat(lat), parseFloat(lng));
// Check if within radius
if (distance <= allowedRadius) {
return {
valid: true,
distance: Math.round(distance),
message: `Within range (${Math.round(distance)}m from office)`
};
} else {
return {
valid: false,
distance: Math.round(distance),
message: `Too far from office (${Math.round(distance)}m, max ${allowedRadius}m)`
};
}
}
// Create attendance record (check-in)
function checkIn(data) {
// Validate location
const locationCheck = validateLocation(data.lat, data.lng);
if (!locationCheck.valid) {
throw new Error(locationCheck.message);
}
const stmt = db.prepare(`
INSERT INTO attendance (
employee_type, employee_id, employee_name,
check_in_time, check_in_lat, check_in_lng, check_in_note, check_in_photo
) VALUES (?, ?, ?, NOW_LOCAL(), ?, ?, ?, ?)
`);
return stmt.run(
data.employee_type,
data.employee_id,
data.employee_name,
data.lat || '',
data.lng || '',
data.note || '',
data.photo || ''
);
}
// Update attendance record (check-out)
function checkOut(attendanceId, data) {
const attendance = db.prepare('SELECT * FROM attendance WHERE id = ?').get(attendanceId);
if (!attendance) {
throw new Error('Attendance record not found');
}
if (attendance.status === 'checked_out') {
throw new Error('Already checked out');
}
// Validate location for check-out
const locationCheck = validateLocation(data.lat, data.lng);
if (!locationCheck.valid) {
throw new Error(locationCheck.message);
}
// Calculate work duration in minutes
const checkInTime = parseDateInTimezone(attendance.check_in_time);
const checkOutTime = new Date();
const durationMinutes = Math.floor((checkOutTime - checkInTime) / 1000 / 60);
const stmt = db.prepare(`
UPDATE attendance
SET check_out_time = NOW_LOCAL(),
check_out_lat = ?,
check_out_lng = ?,
check_out_note = ?,
check_out_photo = ?,
work_duration_minutes = ?,
status = 'checked_out'
WHERE id = ?
`);
return stmt.run(
data.lat || '',
data.lng || '',
data.note || '',
data.photo || '',
durationMinutes,
attendanceId
);
}
// Get today's attendance for an employee
function getTodayAttendance(employeeType, employeeId) {
const stmt = db.prepare(`
SELECT * FROM attendance
WHERE employee_type = ?
AND employee_id = ?
AND date(check_in_time) = date(NOW_LOCAL())
ORDER BY check_in_time DESC
LIMIT 1
`);
return stmt.get(employeeType, employeeId);
}
// Get attendance history for an employee
function getAttendanceHistory(employeeType, employeeId, limit = 30) {
const stmt = db.prepare(`
SELECT * FROM attendance
WHERE employee_type = ? AND employee_id = ?
ORDER BY check_in_time DESC
LIMIT ?
`);
return stmt.all(employeeType, employeeId, limit);
}
// Get all attendance records for a specific date
function getAttendanceByDate(date) {
const stmt = db.prepare(`
SELECT * FROM attendance
WHERE date(check_in_time) = date(?)
ORDER BY check_in_time DESC
`);
return stmt.all(date);
}
// Get attendance records for a date range
function getAttendanceByDateRange(startDate, endDate) {
const stmt = db.prepare(`
SELECT * FROM attendance
WHERE date(check_in_time) BETWEEN date(?) AND date(?)
ORDER BY check_in_time DESC
`);
return stmt.all(startDate, endDate);
}
// Get attendance summary for an employee (monthly)
function getMonthlyAttendanceSummary(employeeType, employeeId, year, month) {
const stmt = db.prepare(`
SELECT
COUNT(*) as total_days,
SUM(CASE WHEN status = 'checked_out' THEN 1 ELSE 0 END) as completed_days,
SUM(work_duration_minutes) as total_minutes,
AVG(work_duration_minutes) as avg_minutes
FROM attendance
WHERE employee_type = ?
AND employee_id = ?
AND strftime('%Y', check_in_time) = ?
AND strftime('%m', check_in_time) = ?
`);
const yearStr = String(year);
const monthStr = String(month).padStart(2, '0');
return stmt.get(employeeType, employeeId, yearStr, monthStr);
}
// Get all attendance for today (for admin dashboard)
function getTodayAllAttendance() {
const stmt = db.prepare(`
SELECT * FROM attendance
WHERE date(check_in_time) = date(NOW_LOCAL())
ORDER BY check_in_time DESC
`);
return stmt.all();
}
// Get attendance statistics for admin
function getAttendanceStats(date = null) {
const dateFilter = date ? `date(check_in_time) = date('${date}')` : `date(check_in_time) = date(NOW_LOCAL())`;
const stmt = db.prepare(`
SELECT
employee_type,
COUNT(*) as total,
SUM(CASE WHEN status = 'checked_in' THEN 1 ELSE 0 END) as checked_in,
SUM(CASE WHEN status = 'checked_out' THEN 1 ELSE 0 END) as checked_out
FROM attendance
WHERE ${dateFilter}
GROUP BY employee_type
`);
return stmt.all();
}
// Check if employee has checked in today
function hasCheckedInToday(employeeType, employeeId) {
const today = getTodayAttendance(employeeType, employeeId);
return today !== undefined;
}
// Get late check-ins (after 8:30 AM)
function getLateCheckIns(date = null) {
const dateFilter = date ? `date(check_in_time) = date('${date}')` : `date(check_in_time) = date(NOW_LOCAL())`;
const stmt = db.prepare(`
SELECT * FROM attendance
WHERE ${dateFilter}
AND time(check_in_time) > '08:30:00'
ORDER BY check_in_time DESC
`);
return stmt.all();
}
// Get employees who haven't checked out
function getNotCheckedOut(date = null) {
const dateFilter = date ? `date(check_in_time) = date('${date}')` : `date(check_in_time) = date(NOW_LOCAL())`;
const stmt = db.prepare(`
SELECT * FROM attendance
WHERE ${dateFilter}
AND status = 'checked_in'
ORDER BY check_in_time DESC
`);
return stmt.all();
}
// Delete attendance record (admin only)
function deleteAttendance(id) {
return db.prepare('DELETE FROM attendance WHERE id = ?').run(id);
}
// Update attendance record (admin only - for corrections)
function updateAttendance(id, data) {
const stmt = db.prepare(`
UPDATE attendance
SET check_in_time = ?,
check_in_note = ?,
check_out_time = ?,
check_out_note = ?,
work_duration_minutes = ?
WHERE id = ?
`);
return stmt.run(
data.check_in_time,
data.check_in_note || '',
data.check_out_time || null,
data.check_out_note || '',
data.work_duration_minutes || 0,
id
);
}
// Get geofencing settings
function getGeofencingSettings() {
return {
enabled: getSetting('attendance_geofencing', 'true') === 'true',
officeLat: parseFloat(getSetting('office_lat', '0')),
officeLng: parseFloat(getSetting('office_lng', '0')),
radius: parseInt(getSetting('attendance_radius', '100'))
};
}
module.exports = {
checkIn,
checkOut,
getTodayAttendance,
getAttendanceHistory,
getAttendanceByDate,
getAttendanceByDateRange,
getMonthlyAttendanceSummary,
getTodayAllAttendance,
getAttendanceStats,
hasCheckedInToday,
getLateCheckIns,
getNotCheckedOut,
deleteAttendance,
updateAttendance,
validateLocation,
calculateDistance,
getGeofencingSettings
};
// Made with Bob
+223
View File
@@ -0,0 +1,223 @@
/**
* Service: Audit Trail untuk Operasi Sensitif
* Mencatat semua operasi sensitif untuk tracking dan security
*/
const db = require('../config/database');
const { logger } = require('../config/logger');
// Inisialisasi tabel audit_trail
function initAuditTrailTable() {
try {
db.exec(`
CREATE TABLE IF NOT EXISTS audit_trail (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
actor_type TEXT NOT NULL,
actor_id TEXT,
actor_name TEXT,
details TEXT,
ip_address TEXT,
user_agent TEXT,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_trail(actor_type, actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_trail(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_trail(action);
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_trail(created_at);
`);
} catch (e) {
logger.error(`[Audit Trail] Gagal inisialisasi tabel: ${e.message}`);
}
}
// Inisialisasi saat module dimuat
initAuditTrailTable();
/**
* Catat audit trail
* @param {Object} data - Data audit
* @param {string} data.action - Tipe aksi (CREATE, UPDATE, DELETE, LOGIN, LOGOUT, dll)
* @param {string} data.entity_type - Tipe entity (customer, invoice, package, dll)
* @param {string} data.entity_id - ID entity
* @param {string} data.actor_type - Tipe actor (admin, cashier, technician, agent, customer, system)
* @param {string} data.actor_id - ID actor
* @param {string} data.actor_name - Nama actor
* @param {Object} data.details - Detail aksi (akan di-JSON.stringify)
* @param {string} data.ip_address - IP address
* @param {string} data.user_agent - User agent
*/
function logAuditTrail(data) {
try {
const stmt = db.prepare(`
INSERT INTO audit_trail (
action, entity_type, entity_id, actor_type, actor_id, actor_name,
details, ip_address, user_agent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
data.action,
data.entity_type,
data.entity_id || null,
data.actor_type,
data.actor_id || null,
data.actor_name || null,
data.details ? JSON.stringify(data.details) : null,
data.ip_address || null,
data.user_agent || null
);
logger.info(`[Audit Trail] ${data.action} ${data.entity_type} by ${data.actor_type} - ${data.actor_name}`);
} catch (e) {
logger.error(`[Audit Trail] Gagal mencatat audit: ${e.message}`);
}
}
/**
* Ambil audit trail berdasarkan filter
* @param {Object} filters - Filter pencarian
* @param {string} filters.action - Filter berdasarkan aksi
* @param {string} filters.entity_type - Filter berdasarkan tipe entity
* @param {string} filters.actor_type - Filter berdasarkan tipe actor
* @param {string} filters.actor_id - Filter berdasarkan ID actor
* @param {number} filters.limit - Batas hasil
* @param {number} filters.offset - Offset hasil
*/
function getAuditTrail(filters = {}) {
try {
let query = 'SELECT * FROM audit_trail WHERE 1=1';
const params = [];
if (filters.action) {
query += ' AND action = ?';
params.push(filters.action);
}
if (filters.entity_type) {
query += ' AND entity_type = ?';
params.push(filters.entity_type);
}
if (filters.actor_type) {
query += ' AND actor_type = ?';
params.push(filters.actor_type);
}
if (filters.actor_id) {
query += ' AND actor_id = ?';
params.push(filters.actor_id);
}
query += ' ORDER BY created_at DESC';
if (filters.limit) {
query += ' LIMIT ?';
params.push(filters.limit);
}
if (filters.offset) {
query += ' OFFSET ?';
params.push(filters.offset);
}
const stmt = db.prepare(query);
const results = stmt.all(...params);
// Parse details JSON
return results.map(row => ({
...row,
details: row.details ? JSON.parse(row.details) : null
}));
} catch (e) {
logger.error(`[Audit Trail] Gagal mengambil audit: ${e.message}`);
return [];
}
}
/**
* Ambil statistik audit trail
*/
function getAuditStats() {
try {
const stats = {
total: 0,
by_action: {},
by_entity_type: {},
by_actor_type: {},
recent_24h: 0
};
// Total audit
const totalStmt = db.prepare('SELECT COUNT(*) as count FROM audit_trail');
stats.total = totalStmt.get().count;
// By action
const actionStmt = db.prepare(`
SELECT action, COUNT(*) as count
FROM audit_trail
GROUP BY action
ORDER BY count DESC
`);
stats.by_action = actionStmt.all();
// By entity type
const entityStmt = db.prepare(`
SELECT entity_type, COUNT(*) as count
FROM audit_trail
GROUP BY entity_type
ORDER BY count DESC
`);
stats.by_entity_type = entityStmt.all();
// By actor type
const actorStmt = db.prepare(`
SELECT actor_type, COUNT(*) as count
FROM audit_trail
GROUP BY actor_type
ORDER BY count DESC
`);
stats.by_actor_type = actorStmt.all();
// Recent 24h
const recentStmt = db.prepare(`
SELECT COUNT(*) as count
FROM audit_trail
WHERE created_at >= datetime(NOW_LOCAL(), '-24 hours')
`);
stats.recent_24h = recentStmt.get().count;
return stats;
} catch (e) {
logger.error(`[Audit Trail] Gagal mengambil statistik: ${e.message}`);
return null;
}
}
/**
* Hapus audit trail lama (retention policy)
* @param {number} days - Jumlah hari untuk dipertahankan (default 90 hari)
*/
function cleanupOldAuditTrail(days = 90) {
try {
const stmt = db.prepare(`
DELETE FROM audit_trail
WHERE created_at < datetime('now', '-${days} days')
`);
const result = stmt.run();
logger.info(`[Audit Trail] Cleaned up ${result.changes} old records (older than ${days} days)`);
return result.changes;
} catch (e) {
logger.error(`[Audit Trail] Gagal cleanup: ${e.message}`);
return 0;
}
}
module.exports = {
logAuditTrail,
getAuditTrail,
getAuditStats,
cleanupOldAuditTrail
};
+413
View File
@@ -0,0 +1,413 @@
/**
* Service: Backup & Recovery System
* Melakukan backup otomatis database dan settings
*/
const fs = require('fs');
const path = require('path');
const { logger } = require('../config/logger');
const { getSetting, getCurrentDateInTimezone, getNowLocalISO } = require('../config/settingsManager');
const projectRoot = path.join(__dirname, '..');
const backupDir = path.join(projectRoot, 'backups');
const dbPath = path.join(projectRoot, 'database', 'billing.db');
const settingsPath = path.join(projectRoot, 'settings.json');
// Pastikan direktori backup ada
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
logger.info('[Backup] Created backup directory');
}
/**
* Generate timestamp untuk nama file backup
*/
function getBackupTimestamp() {
const now = getCurrentDateInTimezone();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
}
/**
* Backup database SQLite
*/
function backupDatabase() {
try {
const timestamp = getBackupTimestamp();
const backupFileName = `billing_db_${timestamp}.db`;
const backupFilePath = path.join(backupDir, backupFileName);
// Copy database file
fs.copyFileSync(dbPath, backupFilePath);
// Compress backup (optional - bisa ditambahkan nanti)
const stats = fs.statSync(backupFilePath);
const sizeKB = Math.round(stats.size / 1024);
logger.info(`[Backup] Database backup created: ${backupFileName} (${sizeKB} KB)`);
return {
success: true,
fileName: backupFileName,
size: stats.size,
timestamp: getNowLocalISO()
};
} catch (e) {
logger.error(`[Backup] Failed to backup database: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Backup settings.json
*/
function backupSettings() {
try {
const timestamp = getBackupTimestamp();
const backupFileName = `settings_${timestamp}.json`;
const backupFilePath = path.join(backupDir, backupFileName);
// Copy settings file
fs.copyFileSync(settingsPath, backupFilePath);
const stats = fs.statSync(backupFilePath);
const sizeKB = Math.round(stats.size / 1024);
logger.info(`[Backup] Settings backup created: ${backupFileName} (${sizeKB} KB)`);
return {
success: true,
fileName: backupFileName,
size: stats.size,
timestamp: getNowLocalISO()
};
} catch (e) {
logger.error(`[Backup] Failed to backup settings: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Backup semua (database + settings)
*/
function backupAll() {
const dbResult = backupDatabase();
const settingsResult = backupSettings();
return {
database: dbResult,
settings: settingsResult,
timestamp: getNowLocalISO()
};
}
/**
* Restore database dari backup
*/
function restoreDatabase(backupFileName) {
try {
const backupFilePath = path.join(backupDir, backupFileName);
// Cek apakah file backup ada
if (!fs.existsSync(backupFilePath)) {
return {
success: false,
error: `Backup file not found: ${backupFileName}`
};
}
// Backup database saat ini sebelum restore
const preRestoreBackup = backupDatabase();
if (!preRestoreBackup.success) {
logger.warn('[Backup] Failed to create pre-restore backup');
}
// Restore database
fs.copyFileSync(backupFilePath, dbPath);
const stats = fs.statSync(dbPath);
const sizeKB = Math.round(stats.size / 1024);
logger.info(`[Backup] Database restored from: ${backupFileName} (${sizeKB} KB)`);
return {
success: true,
fileName: backupFileName,
size: stats.size,
timestamp: getNowLocalISO(),
preRestoreBackup: preRestoreBackup.fileName
};
} catch (e) {
logger.error(`[Backup] Failed to restore database: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Restore settings dari backup
*/
function restoreSettings(backupFileName) {
try {
const backupFilePath = path.join(backupDir, backupFileName);
// Cek apakah file backup ada
if (!fs.existsSync(backupFilePath)) {
return {
success: false,
error: `Backup file not found: ${backupFileName}`
};
}
// Backup settings saat ini sebelum restore
const preRestoreBackup = backupSettings();
if (!preRestoreBackup.success) {
logger.warn('[Backup] Failed to create pre-restore backup');
}
// Restore settings
fs.copyFileSync(backupFilePath, settingsPath);
const stats = fs.statSync(settingsPath);
const sizeKB = Math.round(stats.size / 1024);
logger.info(`[Backup] Settings restored from: ${backupFileName} (${sizeKB} KB)`);
return {
success: true,
fileName: backupFileName,
size: stats.size,
timestamp: getNowLocalISO(),
preRestoreBackup: preRestoreBackup.fileName
};
} catch (e) {
logger.error(`[Backup] Failed to restore settings: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Daftar semua backup yang tersedia
*/
function listBackups() {
try {
const files = fs.readdirSync(backupDir);
const backups = [];
for (const file of files) {
const filePath = path.join(backupDir, file);
const stats = fs.statSync(filePath);
// Parse filename untuk mendapatkan tanggal
let backupDate = null;
let backupType = null;
if (file.startsWith('billing_db_') && file.endsWith('.db')) {
backupType = 'database';
const timestamp = file.replace('billing_db_', '').replace('.db', '');
backupDate = parseBackupTimestamp(timestamp);
} else if (file.startsWith('settings_') && file.endsWith('.json')) {
backupType = 'settings';
const timestamp = file.replace('settings_', '').replace('.json', '');
backupDate = parseBackupTimestamp(timestamp);
}
backups.push({
fileName: file,
type: backupType,
size: stats.size,
sizeKB: Math.round(stats.size / 1024),
created: stats.birthtime,
createdDate: backupDate,
modified: stats.mtime
});
}
// Sort by created date (terbaru dulu)
backups.sort((a, b) => b.created - a.created);
return {
success: true,
backups: backups,
total: backups.length
};
} catch (e) {
logger.error(`[Backup] Failed to list backups: ${e.message}`);
return {
success: false,
error: e.message,
backups: []
};
}
}
/**
* Parse timestamp dari nama file backup
*/
function parseBackupTimestamp(timestamp) {
try {
// Format: YYYYMMDD_HHMMSS
const [datePart, timePart] = timestamp.split('_');
const year = datePart.substring(0, 4);
const month = datePart.substring(4, 6);
const day = datePart.substring(6, 8);
const hours = timePart.substring(0, 2);
const minutes = timePart.substring(2, 4);
const seconds = timePart.substring(4, 6);
return new Date(year, month - 1, day, hours, minutes, seconds);
} catch (e) {
return null;
}
}
/**
* Hapus backup lama berdasarkan retention policy
*/
function cleanupOldBackups(retentionDays = 30) {
try {
const result = listBackups();
if (!result.success) {
return result;
}
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
let deletedCount = 0;
const deletedFiles = [];
for (const backup of result.backups) {
if (backup.created < cutoffDate) {
const filePath = path.join(backupDir, backup.fileName);
fs.unlinkSync(filePath);
deletedCount++;
deletedFiles.push(backup.fileName);
logger.info(`[Backup] Deleted old backup: ${backup.fileName}`);
}
}
return {
success: true,
deletedCount,
deletedFiles,
retentionDays
};
} catch (e) {
logger.error(`[Backup] Failed to cleanup old backups: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Cek kapasitas backup dan hapus jika perlu
*/
function checkBackupCapacity(maxSizeMB = 500) {
try {
const result = listBackups();
if (!result.success) {
return result;
}
const totalSize = result.backups.reduce((sum, backup) => sum + backup.size, 0);
const totalSizeMB = totalSize / (1024 * 1024);
if (totalSizeMB > maxSizeMB) {
logger.warn(`[Backup] Backup size (${totalSizeMB.toFixed(2)} MB) exceeds limit (${maxSizeMB} MB)`);
// Hapus backup paling lama sampai kapasitas aman
const sortedBackups = [...result.backups].sort((a, b) => a.created - b.created);
let deletedCount = 0;
for (const backup of sortedBackups) {
if (totalSizeMB <= maxSizeMB * 0.8) { // Hapus sampai 80% dari limit
break;
}
const filePath = path.join(backupDir, backup.fileName);
fs.unlinkSync(filePath);
totalSizeMB -= backup.size / (1024 * 1024);
deletedCount++;
logger.info(`[Backup] Deleted backup for capacity: ${backup.fileName}`);
}
return {
success: true,
action: 'cleanup',
deletedCount,
totalSizeMB: totalSizeMB.toFixed(2),
maxSizeMB
};
}
return {
success: true,
action: 'none',
totalSizeMB: totalSizeMB.toFixed(2),
maxSizeMB
};
} catch (e) {
logger.error(`[Backup] Failed to check backup capacity: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Jadwal backup otomatis
*/
function scheduleAutoBackup() {
const nodeCron = require('node-cron');
const enabled = getSetting('auto_backup_enabled', true);
const schedule = getSetting('auto_backup_schedule', '0 2 * * *'); // Default jam 2 pagi setiap hari
if (!enabled) {
logger.info('[Backup] Auto backup disabled');
return;
}
nodeCron.schedule(schedule, () => {
logger.info('[Backup] Starting scheduled backup...');
const result = backupAll();
if (result.database.success && result.settings.success) {
logger.info('[Backup] Scheduled backup completed successfully');
} else {
logger.error('[Backup] Scheduled backup failed');
}
});
logger.info(`[Backup] Auto backup scheduled: ${schedule}`);
}
module.exports = {
backupDatabase,
backupSettings,
backupAll,
restoreDatabase,
restoreSettings,
listBackups,
cleanupOldBackups,
checkBackupCapacity,
scheduleAutoBackup
};
+148
View File
@@ -0,0 +1,148 @@
'use strict';
/**
* billingSendService.js — Kirim notifikasi tagihan (manual) dengan opsi:
* - target tanggal isolir (isolate_day)
* - template (dapat dipilih)
* - interval statis atau random (anti-ban)
* Status dipantau dari halaman admin.
*/
const db = require('../config/database');
const { getSetting } = require('../config/settingsManager');
const billingSvc = require('./billingService');
const TEMPLATE_LABELS = {
whatsapp_auto_billing_message: 'Pengingat Tagihan',
whatsapp_billing_qris_message: 'Tagihan QRIS (dengan gambar QR)',
whatsapp_arrears_warning_message: 'Peringatan Tunggakan',
whatsapp_isolir_message: 'Pemberitahuan Isolir',
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let STATUS = {
running: false, total: 0, sent: 0, failed: 0, skipped: 0,
startedAt: null, finishedAt: null, last: '', label: '', stopped: false,
};
function getStatus() { return STATUS; }
function stop() { STATUS.stopped = true; return STATUS; }
function portalLink() {
let b = String(getSetting('public_base_url', '') || '').trim();
if (!b) { const h = String(getSetting('server_host', '') || '').trim(); if (h) b = (/^https?:\/\//i.test(h) ? h : 'http://' + h); }
return (b ? b.replace(/\/+$/, '') : '') + '/customer';
}
function spintax(t) {
return String(t || '').replace(/\{([^{}|]+(?:\|[^{}|]+)+)\}/g, function (m, c) {
const a = c.split('|'); return a[Math.floor(Math.random() * a.length)].trim();
});
}
function buildMessage(tplRaw, c, inv) {
const amount = Number(inv.amount || 0);
const period = inv.period_month + '/' + inv.period_year;
return spintax(String(tplRaw || '')
.replace(/{{id_pelanggan}}/gi, c.customer_code || ('ID:' + c.id))
.replace(/{{nama}}/gi, c.name || 'Pelanggan')
.replace(/{{paket}}/gi, inv.package_name || '-')
.replace(/{{tagihan}}/gi, amount.toLocaleString('id-ID'))
.replace(/{{qris_nominal}}/gi, amount.toLocaleString('id-ID'))
.replace(/{{rincian}}/gi, period)
.replace(/{{periode}}/gi, period)
.replace(/{{bulan_nunggak}}/gi, String(c.unpaid_streak || 0))
.replace(/{{batas}}/gi, String(getSetting('auto_deactivate_months', 3) || 3))
.replace(/{{link}}/gi, portalLink())
.replace(/{{link_portal}}/gi, portalLink())
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
.replace(/{{qris_qr}}/gi, 'QRIS terlampir'));
}
function buildTargets(isolateDay) {
const targets = [];
let q = "SELECT * FROM customers WHERE package_id IS NOT NULL AND status IN ('active','ditangguhkan','suspended')";
const params = [];
if (Number(isolateDay) > 0) { q += ' AND COALESCE(isolate_day,0)=?'; params.push(Number(isolateDay)); }
q += ' ORDER BY name';
const custs = db.prepare(q).all(...params);
for (const c of custs) {
let invs = [];
try { invs = billingSvc.getUnpaidInvoicesByCustomerId(c.id) || []; } catch (e) {}
if (!invs.length) continue;
if (!c.phone || String(c.phone).replace(/\D/g, '').length < 9) continue;
targets.push({ c, inv: invs[invs.length - 1] });
}
return targets;
}
async function start(opts = {}) {
if (STATUS.running) throw new Error('Pengiriman sedang berjalan. Tunggu selesai atau hentikan dulu.');
const isolateDay = parseInt(opts.isolateDay, 10) || 0;
const templateKey = String(opts.templateKey || 'whatsapp_auto_billing_message');
const intervalMode = String(opts.intervalMode || 'static').toLowerCase();
const intervalValue = Math.max(3, parseInt(opts.intervalValue, 10) || 15);
const intervalMin = Math.max(3, parseInt(opts.intervalMin, 10) || 15);
const intervalMax = Math.max(intervalMin, parseInt(opts.intervalMax, 10) || 40);
const isQris = /qris/i.test(templateKey) || templateKey === 'whatsapp_billing_qris_message';
const def = 'Yth. {{nama}},\n\nBerikut tagihan internet Anda:\n📦 {{paket}}\n💰 Rp {{tagihan}}\n📅 {{rincian}}\n\nBayar: {{link}}';
const tplRaw = db.getAppSetting(templateKey, def) || def;
const targets = buildTargets(isolateDay);
if (!targets.length) {
try { db.prepare("INSERT INTO automation_runs (type, template_key, isolate_day, mode, interval_label, target, sent, failed, status, note) VALUES ('manual',?,?,?,'-',0,0,0,'empty','Tidak ada target')").run(templateKey, isolateDay, intervalMode); } catch (e) {}
return { started: false, total: 0, message: 'Tidak ada target (pelanggan belum bayar) untuk tanggal isolir tsb.' };
}
STATUS = {
running: true, total: targets.length, sent: 0, failed: 0, skipped: 0,
startedAt: new Date().toISOString(), finishedAt: null, last: '',
label: (isolateDay ? ('Isolir tgl ' + isolateDay) : 'Semua') + ' • ' + (TEMPLATE_LABELS[templateKey] || templateKey) + ' • ' + (intervalMode === 'random' ? ('acak ' + intervalMin + '-' + intervalMax + 's') : ('statis ' + intervalValue + 's')),
stopped: false,
};
let runId = 0;
try { runId = Number(db.prepare("INSERT INTO automation_runs (type, template_key, isolate_day, mode, interval_label, target, sent, failed, status) VALUES ('manual',?,?,?,?,?,0,0,'running')").run(templateKey, isolateDay, intervalMode, STATUS.label, targets.length).lastInsertRowid) || 0; } catch (e) {}
const waNotify = require('./waNotifyService');
let QRCode = null, ensureInvoiceQris = null;
if (isQris) { try { QRCode = require('/opt/billing-rtrw/node_modules/qrcode'); } catch (e) {} try { ensureInvoiceQris = require('./invoiceQrisService').ensureInvoiceQris; } catch (e) {} }
(async () => {
for (let i = 0; i < targets.length; i++) {
if (STATUS.stopped) break;
const { c, inv } = targets[i];
try {
const caption = buildMessage(tplRaw, c, inv);
if (isQris && QRCode && ensureInvoiceQris) {
const qr = ensureInvoiceQris(inv) || {};
if (qr.payload) {
const url = await QRCode.toDataURL(qr.payload, { errorCorrectionLevel: 'M', margin: 1, width: 520 });
const buf = Buffer.from(String(url).split(',')[1], 'base64');
await waNotify.sendImage(c.phone, buf, caption);
} else {
await waNotify.sendText(c.phone, caption);
}
} else {
await waNotify.sendText(c.phone, caption);
}
STATUS.sent++; STATUS.last = c.name;
} catch (e) { STATUS.failed++; STATUS.last = c.name + ' (gagal)'; }
if (i < targets.length - 1 && !STATUS.stopped) {
const d = intervalMode === 'random'
? (intervalMin + Math.floor(Math.random() * (intervalMax - intervalMin + 1)))
: intervalValue;
await sleep(d * 1000);
}
}
STATUS.running = false;
STATUS.finishedAt = new Date().toISOString();
if (runId) { try { db.prepare("UPDATE automation_runs SET sent=?, failed=?, status=?, finished_at=(NOW_LOCAL()) WHERE id=?").run(STATUS.sent, STATUS.failed, STATUS.stopped ? 'stopped' : 'done', runId); } catch (e) {} }
})();
return { started: true, total: targets.length, label: STATUS.label };
}
module.exports = { start, getStatus, stop, TEMPLATE_LABELS, buildTargets };
+814
View File
@@ -0,0 +1,814 @@
/**
* Service: Logika Billing & Tagihan
*/
const db = require('../config/database');
const auditTrail = require('./auditTrailService');
const { getCurrentDateInTimezone, getSetting } = require('../config/settingsManager');
const { logger } = require('../config/logger');
function daysInMonth(year, month1to12) {
return new Date(year, month1to12, 0).getDate();
}
function parseInstallYMD(installDate) {
if (!installDate || typeof installDate !== 'string') return null;
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(installDate.trim());
if (!m) return null;
const y = parseInt(m[1], 10);
const mo = parseInt(m[2], 10);
const d = parseInt(m[3], 10);
if (!y || mo < 1 || mo > 12 || d < 1 || d > 31) return null;
return { y, m: mo, d };
}
function countInvoicesForCustomer(customerId) {
const r = db.prepare('SELECT COUNT(*) as c FROM invoices WHERE customer_id=?').get(customerId);
return r ? Number(r.c) || 0 : 0;
}
/**
* Hitung nominal tagihan + catatan otomatis (promo siklus & prorata bulan pertama).
* Promo: pakai promo_price untuk N invoice pertama per pelanggan (promo_cycles), lalu harga normal.
* Prorata: jika paket mengaktifkan prorate_first_invoice, belum pernah ada invoice,
* tanggal pasang (install_date) di bulan/tahun tagihan yang sama → proporsi sisa hari bulan.
*/
function computeInvoiceAmountAndMeta(customer, pkg, periodMonth, periodYear) {
const price = Number(pkg.price) || 0;
const promoRaw = pkg.promo_price;
const promoPrice = promoRaw != null && promoRaw !== '' ? Number(promoRaw) : null;
const promoCycles = Math.max(0, parseInt(pkg.promo_cycles, 10) || 0);
const promoUsed = Math.max(0, parseInt(customer.promo_cycles_used, 10) || 0);
const prorateEnabled = !!pkg.prorate_first_invoice;
const usePromo = promoPrice != null && Number.isFinite(promoPrice) && promoCycles > 0 && promoUsed < promoCycles;
let amount = usePromo ? promoPrice : price;
const priorCount = countInvoicesForCustomer(customer.id);
const isFirstEverInvoice = priorCount === 0;
let prorated = false;
let billableDays = null;
let dim = null;
if (prorateEnabled && isFirstEverInvoice && customer.install_date) {
const inst = parseInstallYMD(String(customer.install_date));
if (inst && inst.y === periodYear && inst.m === periodMonth) {
dim = daysInMonth(periodYear, periodMonth);
billableDays = Math.min(dim, Math.max(1, dim - inst.d + 1));
amount = Math.round(amount * (billableDays / dim));
prorated = billableDays < dim;
}
}
const baseAmount = amount;
let taxAmount = 0;
const metaParts = [];
if (usePromo) {
metaParts.push(`Promo siklus ${promoUsed + 1}/${promoCycles} @ Rp ${Number(promoPrice).toLocaleString('id-ID')}`);
}
if (prorated && billableDays != null && dim != null) {
metaParts.push(`Prorata ${billableDays}/${dim} hari`);
}
// PPN Calculation
if (pkg.use_ppn === 1) {
const ppnPct = Number(pkg.ppn_percentage) || 11.0;
const ppnVal = Math.round(baseAmount * (ppnPct / 100));
taxAmount += ppnVal;
metaParts.push(`PPN ${ppnPct}% (Rp ${ppnVal.toLocaleString('id-ID')})`);
}
// USO Calculation
if (pkg.use_uso === 1) {
const usoPct = Number(pkg.uso_percentage) || 1.75;
const usoVal = Math.round(baseAmount * (usoPct / 100));
taxAmount += usoVal;
metaParts.push(`USO ${usoPct}% (Rp ${usoVal.toLocaleString('id-ID')})`);
}
const finalAmount = baseAmount + taxAmount;
const notesAuto = metaParts.length ? `AUTO: ${metaParts.join(' | ')}` : '';
return {
amount: Math.max(0, Math.round(finalAmount)),
bumpPromo: usePromo,
notesAuto
};
}
function generateMonthlyInvoices(month, year) {
// Hanya generate untuk pelanggan aktif dan ditangguhkan. Pelanggan isolir (suspended) TIDAK di-generate tagihan baru.
const customers = db.prepare("SELECT * FROM customers WHERE status IN ('active','ditangguhkan','suspended') AND package_id IS NOT NULL").all();
const existing = db.prepare('SELECT customer_id FROM invoices WHERE period_month=? AND period_year=?').all(month, year);
const existingIds = new Set(existing.map(e => e.customer_id));
const insert = db.prepare(`INSERT INTO invoices (customer_id, period_month, period_year, amount, notes) VALUES (?, ?, ?, ?, ?)`);
const bumpPromo = db.prepare('UPDATE customers SET promo_cycles_used = COALESCE(promo_cycles_used,0) + 1 WHERE id=?');
let created = 0;
const autoPaid = [];
const run = db.transaction(() => {
for (const c of customers) {
if (existingIds.has(c.id)) continue;
const pkg = db.prepare('SELECT * FROM packages WHERE id=?').get(c.package_id);
if (!pkg) continue;
const { amount, bumpPromo: bump, notesAuto } = computeInvoiceAmountAndMeta(c, pkg, month, year);
const ir = insert.run(c.id, month, year, amount, notesAuto);
if (bump) bumpPromo.run(c.id);
voidPreviousUnpaidInvoices(c.id, month, year, `Dihanguskan otomatis: tagihan baru periode ${month}/${year} terbit`);
const ap = autoPayInvoiceIfNeeded(ir.lastInsertRowid, c, amount, notesAuto);
if (ap) { ap.periodText = `${month}/${year}`; autoPaid.push(ap); }
// Jika pelanggan berstatus 'ditangguhkan', otomatis ubah kembali ke 'active' saat tagihan baru terbit di awal bulan
if (c.status === 'ditangguhkan') {
db.prepare("UPDATE customers SET status = 'active' WHERE id = ?").run(c.id);
}
created++;
}
});
run();
for (const ap of autoPaid) { try { notifyAutoPaid(ap.customer, ap.invoiceId, ap.periodText, ap.amount, ap.reason); } catch (e) {} }
if (autoPaid.length) { try { logger.info(`[Billing] ${autoPaid.length} tagihan auto-lunas (auto isolir OFF / nominal 0).`); } catch (e) {} }
return created;
}
function generateInvoiceForCustomer(customerId, month, year) {
const cid = Number(customerId);
const m = Number(month);
const y = Number(year);
if (!Number.isFinite(cid) || cid <= 0) throw new Error('Customer ID tidak valid');
if (!Number.isFinite(m) || m < 1 || m > 12) throw new Error('Bulan tidak valid');
if (!Number.isFinite(y) || y < 2000 || y > 3000) throw new Error('Tahun tidak valid');
const customer = db.prepare('SELECT * FROM customers WHERE id=?').get(cid);
if (!customer) throw new Error('Pelanggan tidak ditemukan');
if (!customer.package_id) throw new Error('Pelanggan belum memiliki paket');
const exists = db.prepare('SELECT id FROM invoices WHERE customer_id=? AND period_month=? AND period_year=? LIMIT 1').get(cid, m, y);
if (exists) {
return { created: false, invoiceId: exists.id, customerName: customer.name };
}
const pkg = db.prepare('SELECT * FROM packages WHERE id=?').get(customer.package_id);
if (!pkg) throw new Error('Paket pelanggan tidak ditemukan');
const { amount, bumpPromo: bump, notesAuto } = computeInvoiceAmountAndMeta(customer, pkg, m, y);
const r = db.prepare('INSERT INTO invoices (customer_id, period_month, period_year, amount, notes) VALUES (?, ?, ?, ?, ?)').run(cid, m, y, amount, notesAuto);
if (bump) {
db.prepare('UPDATE customers SET promo_cycles_used = COALESCE(promo_cycles_used,0) + 1 WHERE id=?').run(cid);
}
voidPreviousUnpaidInvoices(cid, m, y, `Dihanguskan otomatis: tagihan baru periode ${m}/${y} terbit`);
const ap = autoPayInvoiceIfNeeded(r.lastInsertRowid, customer, amount, notesAuto);
if (ap) { try { notifyAutoPaid(ap.customer, ap.invoiceId, `${m}/${y}`, ap.amount, ap.reason); } catch (e) {} }
return { created: true, invoiceId: r.lastInsertRowid, customerName: customer.name };
}
function payInvoiceForCustomerPeriod(customerId, month, year, paidByName, notes) {
const cid = Number(customerId);
const m = Number(month);
const y = Number(year);
if (!Number.isFinite(cid) || cid <= 0) throw new Error('Customer ID tidak valid');
if (!Number.isFinite(m) || m < 1 || m > 12) throw new Error('Bulan tidak valid');
if (!Number.isFinite(y) || y < 2000 || y > 3000) throw new Error('Tahun tidak valid');
const customer = db.prepare('SELECT id, name FROM customers WHERE id=?').get(cid);
if (!customer) throw new Error('Pelanggan tidak ditemukan');
const inv = db.prepare('SELECT id, status FROM invoices WHERE customer_id=? AND period_month=? AND period_year=? LIMIT 1').get(cid, m, y);
if (inv && inv.status === 'paid') {
return { created: false, paid: false, alreadyPaid: true, invoiceId: inv.id, customerName: customer.name };
}
const ensure = generateInvoiceForCustomer(cid, m, y);
markAsPaid(ensure.invoiceId, paidByName, notes);
return { created: ensure.created, paid: true, alreadyPaid: false, invoiceId: ensure.invoiceId, customerName: ensure.customerName };
}
function payInvoicesForCustomerMonths(customerId, year, months, paidByName, notes) {
const cid = Number(customerId);
const y = Number(year);
if (!Number.isFinite(cid) || cid <= 0) throw new Error('Customer ID tidak valid');
if (!Number.isFinite(y) || y < 2000 || y > 3000) throw new Error('Tahun tidak valid');
const rawMonths = Array.isArray(months) ? months : (months == null ? [] : [months]);
const selectedMonths = [...new Set(rawMonths.map(m => parseInt(m)).filter(m => Number.isFinite(m) && m >= 1 && m <= 12))].sort((a, b) => a - b);
if (selectedMonths.length === 0) throw new Error('Pilih minimal 1 bulan');
const customer = db.prepare('SELECT * FROM customers WHERE id=?').get(cid);
if (!customer) throw new Error('Pelanggan tidak ditemukan');
if (!customer.package_id) throw new Error('Pelanggan belum memiliki paket');
const pkg = db.prepare('SELECT * FROM packages WHERE id=?').get(customer.package_id);
if (!pkg) throw new Error('Paket pelanggan tidak ditemukan');
const selectInv = db.prepare('SELECT id, status, amount FROM invoices WHERE customer_id=? AND period_month=? AND period_year=? LIMIT 1');
const insertInv = db.prepare('INSERT INTO invoices (customer_id, period_month, period_year, amount, notes) VALUES (?, ?, ?, ?, ?)');
const bumpPromo = db.prepare('UPDATE customers SET promo_cycles_used = COALESCE(promo_cycles_used,0) + 1 WHERE id=?');
const payInv = db.prepare(`UPDATE invoices SET status='paid', paid_at=NOW_LOCAL(), paid_by_name=?, notes=? WHERE id=?`);
const summary = { customerName: customer.name, year: y, paidMonths: [], alreadyPaidMonths: [], createdMonths: [], totalAmount: 0, totalMonths: 0 };
const run = db.transaction(() => {
for (const m of selectedMonths) {
const inv = selectInv.get(cid, m, y);
if (inv && inv.status === 'paid') {
summary.alreadyPaidMonths.push(m);
continue;
}
let invoiceId = inv ? inv.id : null;
let amount = inv ? Number(inv.amount) : 0;
if (!invoiceId) {
const { amount: computed, bumpPromo: bump, notesAuto } = computeInvoiceAmountAndMeta(customer, pkg, m, y);
const r = insertInv.run(cid, m, y, computed, notesAuto);
invoiceId = r.lastInsertRowid;
summary.createdMonths.push(m);
amount = computed;
if (bump) bumpPromo.run(cid);
}
payInv.run(paidByName || 'Admin', notes || '', invoiceId);
summary.paidMonths.push(m);
summary.totalAmount += (Number.isFinite(amount) ? amount : 0);
summary.totalMonths += 1;
}
});
run();
if (summary.totalMonths > 0) {
renewCustomerPrepaidValidity(cid, summary.totalMonths);
try { db.prepare("UPDATE customers SET unpaid_streak = 0, deactivate_cause = '', deactivated_at = NULL WHERE id = ?").run(cid); } catch (e) {}
}
return summary;
}
function getPaidMonthsForCustomerYear(customerId, year) {
const cid = Number(customerId);
const y = Number(year);
if (!Number.isFinite(cid) || cid <= 0) throw new Error('Customer ID tidak valid');
if (!Number.isFinite(y) || y < 2000 || y > 3000) throw new Error('Tahun tidak valid');
const rows = db.prepare(`
SELECT period_month
FROM invoices
WHERE customer_id=? AND period_year=? AND status='paid'
ORDER BY period_month ASC
`).all(cid, y);
return rows.map(r => r.period_month);
}
function getCustomerBillingYearSummary(customerId, year) {
const cid = Number(customerId);
const y = Number(year);
if (!Number.isFinite(cid) || cid <= 0) throw new Error('Customer ID tidak valid');
if (!Number.isFinite(y) || y < 2000 || y > 3000) throw new Error('Tahun tidak valid');
const customer = db.prepare(`
SELECT c.id, c.name, p.price as package_price
FROM customers c
LEFT JOIN packages p ON c.package_id = p.id
WHERE c.id=?
`).get(cid);
if (!customer) throw new Error('Pelanggan tidak ditemukan');
const invoices = db.prepare(`
SELECT period_month as month, status, amount
FROM invoices
WHERE customer_id=? AND period_year=?
ORDER BY period_month ASC
`).all(cid, y);
return {
customerId: customer.id,
customerName: customer.name,
year: y,
packagePrice: customer.package_price || 0,
invoices
};
}
function getAllInvoices({ month, year, status, search, packageId, area, routerId, collectorId, limit = 300 } = {}) {
let q = `
SELECT i.*, c.name as customer_name, c.phone as customer_phone, c.genieacs_tag, c.isolate_day as isolate_day, 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 1=1
`;
const params = [];
if (month) { q += ' AND i.period_month=?'; params.push(parseInt(month)); }
if (year) { q += ' AND i.period_year=?'; params.push(parseInt(year)); }
if (status && status !== 'all') { q += ' AND i.status=?'; params.push(status); }
if (search) {
q += ' AND (c.name LIKE ? OR c.phone LIKE ? OR c.genieacs_tag LIKE ? OR c.customer_code LIKE ?)';
const s = `%${search}%`;
params.push(s, s, s, s);
}
// ── Filter lanjutan ──
if (packageId) { q += ' AND c.package_id = ?'; params.push(Number(packageId)); }
if (routerId) { q += ' AND c.router_id = ?'; params.push(Number(routerId)); }
if (collectorId) { q += ' AND c.collector_id = ?'; params.push(Number(collectorId)); }
if (area) { q += ' AND LOWER(TRIM(c.area)) = LOWER(TRIM(?))'; params.push(area); }
q += ` ORDER BY i.period_year DESC, i.period_month DESC, c.name ASC LIMIT ${parseInt(limit)}`;
const rows = db.prepare(q).all(...params);
return rows;
}
function getInvoiceById(id) {
return db.prepare(`
SELECT i.*, c.name as customer_name, c.phone as customer_phone, c.address, c.genieacs_tag,
c.customer_code as customer_code, c.pppoe_username as customer_pppoe,
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 i.id = ?
`).get(id);
}
function renewCustomerPrepaidValidity(customerId, multiplier = 1) {
try {
const customer = db.prepare(`
SELECT c.*, p.billing_type, p.duration_days
FROM customers c
LEFT JOIN packages p ON c.package_id = p.id
WHERE c.id = ?
`).get(customerId);
if (!customer || customer.billing_type !== 'prepaid') {
return null;
}
const durationDays = (parseInt(customer.duration_days, 10) || 30) * Math.max(1, parseInt(multiplier, 10) || 1);
const now = new Date();
let baseDate = now;
// If customer has expired_at and it is still in the future:
if (customer.expired_at) {
const currentExpiry = new Date(customer.expired_at);
if (!isNaN(currentExpiry.getTime()) && currentExpiry > now) {
baseDate = currentExpiry;
}
}
const nextExpiry = new Date(baseDate);
nextExpiry.setDate(nextExpiry.getDate() + durationDays);
const y = nextExpiry.getFullYear();
const m = String(nextExpiry.getMonth() + 1).padStart(2, '0');
const d = String(nextExpiry.getDate()).padStart(2, '0');
const newExpiredAt = `${y}-${m}-${d} 23:59:59`;
db.prepare('UPDATE customers SET expired_at = ? WHERE id = ?').run(newExpiredAt, customerId);
// If customer was suspended / isolated, automatically activate & sync
if (customer.status === 'suspended') {
try {
const customerSvc = require('./customerService');
customerSvc.activateCustomer(customerId).catch(err => {
logger.warn(`[Prepaid Renewal] Auto activate error for ${customer.name}: ${err.message}`);
});
} catch (e) {}
}
return newExpiredAt;
} catch (err) {
logger.error(`[Prepaid Renewal] Error renewing customer ${customerId}:`, err);
return null;
}
}
function markAsPaid(invoiceId, paidByName, notes, actor = null) {
const invId = Number(invoiceId);
if (!invId) return { changes: 0, error: 'Invalid invoice ID' };
const existing = db.prepare('SELECT id, customer_id, period_month, period_year, amount, status, notes FROM invoices WHERE id=?').get(invId);
if (!existing) return { changes: 0, error: 'Invoice tidak ditemukan' };
if (existing.status === 'paid') {
return { changes: 0, alreadyPaid: true, invoice: existing };
}
// Gabungkan notes jika sudah ada
let finalNotes = existing.notes || '';
if (notes) {
finalNotes = finalNotes ? `${finalNotes} | ${notes}` : notes;
}
const result = db.prepare(`
UPDATE invoices SET status='paid', paid_at=NOW_LOCAL(), paid_by_name=?, notes=? WHERE id=? AND status != 'paid'
`).run(paidByName || 'Admin', finalNotes, invId);
if (result.changes > 0 && existing.customer_id) {
renewCustomerPrepaidValidity(existing.customer_id, 1);
try { db.prepare("UPDATE customers SET unpaid_streak = 0, deactivate_cause = '', deactivated_at = NULL WHERE id = ?").run(existing.customer_id); } catch (e) {}
}
// Catat audit trail jika berhasil
if (result.changes > 0 && actor) {
auditTrail.logAuditTrail({
action: 'MARK_INVOICE_PAID',
entity_type: 'invoice',
entity_id: String(invId),
actor_type: actor.type || 'unknown',
actor_id: actor.id || null,
actor_name: actor.name || null,
details: {
customer_id: existing.customer_id,
period: `${existing.period_month}/${existing.period_year}`,
amount: existing.amount,
paid_by: paidByName || 'Admin',
notes: finalNotes
},
ip_address: actor.ip || null,
user_agent: actor.userAgent || null
});
}
return result;
}
function markAsUnpaid(invoiceId) {
return db.prepare(`UPDATE invoices SET status='unpaid', paid_at=NULL, paid_by_name='', notes='' WHERE id=?`).run(invoiceId);
}
function deleteInvoice(id, actor = null) {
const invoice = db.prepare('SELECT id, customer_id, period_month, period_year, amount FROM invoices WHERE id=?').get(id);
const result = db.prepare('DELETE FROM invoices WHERE id=?').run(id);
// Catat audit trail jika berhasil
if (result.changes > 0 && actor && invoice) {
auditTrail.logAuditTrail({
action: 'DELETE_INVOICE',
entity_type: 'invoice',
entity_id: String(id),
actor_type: actor.type || 'unknown',
actor_id: actor.id || null,
actor_name: actor.name || null,
details: {
customer_id: invoice.customer_id,
period: `${invoice.period_month}/${invoice.period_year}`,
amount: invoice.amount
},
ip_address: actor.ip || null,
user_agent: actor.userAgent || null
});
}
return result;
}
function getInvoiceSummary(month, year) {
const total = db.prepare('SELECT COUNT(*) as count, SUM(amount) as total FROM invoices WHERE period_month=? AND period_year=?').get(month, year);
const paid = db.prepare("SELECT COUNT(*) as count, SUM(amount) as total FROM invoices WHERE period_month=? AND period_year=? AND status='paid'").get(month, year);
const unpaid = db.prepare("SELECT COUNT(*) as count, SUM(amount) as total FROM invoices WHERE period_month=? AND period_year=? AND status='unpaid'").get(month, year);
const voided = db.prepare("SELECT COUNT(*) as count, SUM(amount) as total FROM invoices WHERE period_month=? AND period_year=? AND status='void'").get(month, year);
return { total, paid, unpaid, voided };
}
function getMonthlyRevenue(year) {
return db.prepare(`
SELECT period_month as month,
SUM(CASE WHEN status='paid' THEN amount ELSE 0 END) as revenue,
COUNT(*) as total_invoices,
SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) as paid_count,
SUM(CASE WHEN status='unpaid' THEN 1 ELSE 0 END) as unpaid_count
FROM invoices WHERE period_year=?
GROUP BY period_month ORDER BY period_month
`).all(year);
}
function getDashboardStats() {
const now = getCurrentDateInTimezone();
const m = now.getMonth() + 1;
const y = now.getFullYear();
const totalRevenue = db.prepare("SELECT SUM(amount) as t FROM invoices WHERE status='paid'").get();
const thisMonth = db.prepare("SELECT SUM(amount) as t FROM invoices WHERE status='paid' AND period_month=? AND period_year=?").get(m, y);
const pendingAmount = db.prepare("SELECT SUM(amount) as t FROM invoices WHERE status='unpaid'").get();
const unpaidCount = db.prepare("SELECT COUNT(*) as c FROM invoices WHERE status='unpaid'").get();
return {
totalRevenue: totalRevenue.t || 0,
thisMonth: thisMonth.t || 0,
pendingAmount: pendingAmount.t || 0,
unpaidCount: unpaidCount.c || 0,
};
}
function getRecentPayments(limit = 8) {
return db.prepare(`
SELECT i.*, c.name as customer_name FROM invoices i
JOIN customers c ON i.customer_id = c.id
WHERE i.status='paid' ORDER BY i.paid_at DESC LIMIT ?
`).all(limit);
}
function getTopUnpaid(limit = 5) {
return db.prepare(`
SELECT c.name, c.phone, COUNT(*) as unpaid_count, SUM(i.amount) as total_unpaid
FROM invoices i JOIN customers c ON i.customer_id = c.id
WHERE i.status='unpaid'
GROUP BY c.id ORDER BY unpaid_count DESC LIMIT ?
`).all(limit);
}
function getInvoicesByAny(val) {
if (!val) return [];
const raw = String(val || '').trim();
const cleanVal = raw.replace(/\D/g, '');
// Find customer ID first using phone, pppoe, or genieacs_tag
let customer = null;
if (cleanVal.length >= 8) {
customer = db.prepare(`SELECT id FROM customers WHERE phone LIKE ?`).get(`%${cleanVal}%`);
}
if (!customer) {
customer = db.prepare(`SELECT id FROM customers WHERE pppoe_username = ? OR genieacs_tag = ?`).get(raw, raw);
}
if (customer) {
return db.prepare(`
SELECT i.*,
c.name as customer_name,
c.phone as customer_phone,
c.address as customer_address,
c.pppoe_username,
c.genieacs_tag,
c.connection_type,
c.static_ip,
c.status as customer_status,
c.router_id,
c.install_date,
c.isolate_day,
c.isolir_profile,
p.name as package_name,
p.price as package_price,
r.name as router_name
FROM invoices i
JOIN customers c ON i.customer_id = c.id
LEFT JOIN packages p ON c.package_id = p.id
LEFT JOIN routers r ON c.router_id = r.id
WHERE i.customer_id = ?
ORDER BY i.period_year DESC, i.period_month DESC
`).all(customer.id);
}
const keyword = raw.toLowerCase();
if (keyword.length < 3) return [];
return db.prepare(`
SELECT i.*,
c.name as customer_name,
c.phone as customer_phone,
c.address as customer_address,
c.pppoe_username,
c.genieacs_tag,
c.connection_type,
c.static_ip,
c.mac_address,
c.status as customer_status,
c.router_id,
c.install_date,
c.isolate_day,
c.isolir_profile,
p.name as package_name,
p.price as package_price,
r.name as router_name
FROM invoices i
JOIN customers c ON i.customer_id = c.id
LEFT JOIN packages p ON c.package_id = p.id
LEFT JOIN routers r ON c.router_id = r.id
WHERE lower(c.name) LIKE ?
OR lower(c.phone) LIKE ?
OR lower(c.genieacs_tag) LIKE ?
OR lower(c.pppoe_username) LIKE ?
OR lower(c.mac_address) LIKE ?
ORDER BY i.period_year DESC, i.period_month DESC
LIMIT 300
`).all(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
function getUnpaidInvoicesByCustomerId(customerId) {
return db.prepare(`
SELECT i.*, 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 i.customer_id = ? AND i.status = 'unpaid'
ORDER BY i.period_year ASC, i.period_month ASC
`).all(customerId);
}
function getTodayRevenue() {
return db.prepare(`
SELECT SUM(amount) as total, COUNT(*) as count
FROM invoices
WHERE status='paid' AND date(paid_at) = date(NOW_LOCAL())
`).get();
}
/**
* Buat tagihan susulan untuk bulan kalender **tanggal pasang** (prorata sisa hari),
* hanya jika belum ada invoice periode itu. Dasar nominal: **harga reguler** paket (bukan harga promo).
*/
function createInstallProrataCatchUpInvoice(customerId) {
const cid = Number(customerId);
if (!Number.isFinite(cid) || cid <= 0) throw new Error('ID pelanggan tidak valid');
const customer = db.prepare('SELECT * FROM customers WHERE id=?').get(cid);
if (!customer) throw new Error('Pelanggan tidak ditemukan');
if (!customer.package_id) throw new Error('Pelanggan belum memiliki paket');
if (!customer.install_date) throw new Error('Isi tanggal pasang (install_date) di data pelanggan');
const inst = parseInstallYMD(String(customer.install_date));
if (!inst) throw new Error('Format tanggal pasang tidak valid (gunakan YYYY-MM-DD)');
const pkg = db.prepare('SELECT * FROM packages WHERE id=?').get(customer.package_id);
if (!pkg) throw new Error('Paket tidak ditemukan');
if (!pkg.prorate_first_invoice) throw new Error('Paket ini belum mengaktifkan opsi prorata tagihan pertama');
const periodMonth = inst.m;
const periodYear = inst.y;
const exists = db.prepare('SELECT id FROM invoices WHERE customer_id=? AND period_month=? AND period_year=? LIMIT 1').get(cid, periodMonth, periodYear);
if (exists) {
throw new Error(`Sudah ada tagihan untuk periode pasang ${String(periodMonth).padStart(2, '0')}/${periodYear}`);
}
const dim = daysInMonth(periodYear, periodMonth);
const billableDays = Math.min(dim, Math.max(1, dim - inst.d + 1));
const basePrice = Number(pkg.price) || 0;
const baseAmount = Math.max(0, Math.round(basePrice * (billableDays / dim)));
let taxAmount = 0;
const metaParts = [`Susulan prorata bulan pasang (${billableDays}/${dim} hari, dasar harga reguler Rp ${basePrice.toLocaleString('id-ID')})`];
// PPN
if (pkg.use_ppn === 1) {
const ppnPct = Number(pkg.ppn_percentage) || 11.0;
const ppnVal = Math.round(baseAmount * (ppnPct / 100));
taxAmount += ppnVal;
metaParts.push(`PPN ${ppnPct}% (Rp ${ppnVal.toLocaleString('id-ID')})`);
}
// USO
if (pkg.use_uso === 1) {
const usoPct = Number(pkg.uso_percentage) || 1.75;
const usoVal = Math.round(baseAmount * (usoPct / 100));
taxAmount += usoVal;
metaParts.push(`USO ${usoPct}% (Rp ${usoVal.toLocaleString('id-ID')})`);
}
const finalAmount = baseAmount + taxAmount;
const notesAuto = `AUTO: ${metaParts.join(' | ')}`;
const r = db.prepare('INSERT INTO invoices (customer_id, period_month, period_year, amount, notes) VALUES (?, ?, ?, ?, ?)').run(
cid, periodMonth, periodYear, finalAmount, notesAuto
);
return {
invoiceId: r.lastInsertRowid,
amount: finalAmount,
periodMonth,
periodYear,
customerName: customer.name,
billableDays,
daysInMonth: dim
};
}
/**
* Hanguskan (void) semua tagihan BELUM BAYAR pelanggan yang periodenya SEBELUM periode baru.
* Dipanggil saat tagihan baru terbit. Menambah counter unpaid_streak (persiapan auto-nonaktif 3 bulan).
*/
function voidPreviousUnpaidInvoices(customerId, month, year, reason) {
const cid = Number(customerId);
const m = Number(month);
const y = Number(year);
if (!cid || !m || !y) return { voided: 0, total: 0 };
const rows = db.prepare(`
SELECT id, amount FROM invoices
WHERE customer_id=? AND status='unpaid'
AND (period_year < ? OR (period_year = ? AND period_month < ?))
`).all(cid, y, y, m);
if (!rows.length) return { voided: 0, total: 0 };
const upd = db.prepare(`
UPDATE invoices
SET status='void', voided_at=NOW_LOCAL(), void_reason=?,
qris_unique_code=NULL, qris_amount_unique=NULL, qris_assigned_at=NULL
WHERE id=? AND status='unpaid'
`);
const tx = db.transaction(() => { for (const r of rows) upd.run(reason, r.id); });
tx();
db.prepare('UPDATE customers SET unpaid_streak = COALESCE(unpaid_streak,0) + 1 WHERE id=?').run(cid);
return { voided: rows.length, total: rows.reduce((s, r) => s + (Number(r.amount) || 0), 0) };
}
/**
* Auto-lunas saat tagihan terbit bila pelanggan auto_isolate OFF atau nominal Rp 0.
* Dokumen tetap tercatat; pembayaran diverifikasi otomatis oleh sistem.
* Tujuan: menghindari nominal tidak valid saat generate QRIS.
*/
function autoPayInvoiceIfNeeded(invoiceId, customer, amount, existingNotes) {
const reasons = [];
if (Number(customer.auto_isolate) === 0) reasons.push('Auto Isolir OFF');
if (Number(amount) === 0) reasons.push('Nominal Rp 0');
if (!reasons.length) return null;
const note = `AUTO-LUNAS: ${reasons.join(' / ')} (verifikasi sistem)`;
const finalNotes = existingNotes ? `${existingNotes} | ${note}` : note;
const res = db.prepare(`UPDATE invoices SET status='paid', paid_at=NOW_LOCAL(), paid_by_name=?, notes=? WHERE id=? AND status='unpaid'`).run('Sistem (Auto)', finalNotes, invoiceId);
if (!res.changes) return null;
try { db.prepare("UPDATE customers SET unpaid_streak=0, deactivate_cause='', deactivated_at=NULL WHERE id=?").run(customer.id); } catch (e) {}
return { invoiceId, customer, amount, reason: reasons.join(' / ') };
}
function notifyAutoPaid(customer, invoiceId, periodText, amount, reason) {
try {
if (!customer || !customer.phone) return;
if (!getSetting('whatsapp_enabled', false)) return;
const waSvc = require('./whatsappService');
if (!waSvc || typeof waSvc.sendWhatsAppMessage !== 'function') return;
const msg = `✅ *PEMBAYARAN LUNAS*\n\nYth. *${customer.name}*,\nTagihan periode *${periodText}* sebesar *Rp ${Number(amount || 0).toLocaleString('id-ID')}* telah *LUNAS otomatis oleh sistem* (${reason}).\n\nDokumen tagihan tetap tercatat. Terima kasih.`;
waSvc.sendWhatsAppMessage(customer.phone, msg).catch(() => {});
} catch (e) {}
}
/**
* Lunaskan otomatis SEMUA tagihan unpaid pelanggan yang memenuhi alasan auto-lunas
* (auto_isolate=0 atau nominal Rp 0). Untuk retroaktif saat admin set auto_isolate=0.
*/
function autoLunasUnpaidForCustomer(customerId, opts = {}) {
const cid = Number(customerId);
if (!cid) return { count: 0, items: [] };
const customer = db.prepare('SELECT * FROM customers WHERE id=?').get(cid);
if (!customer) return { count: 0, items: [] };
const invoices = db.prepare("SELECT * FROM invoices WHERE customer_id=? AND status='unpaid'").all(cid);
const items = [];
for (const inv of invoices) {
const reasons = [];
if (Number(customer.auto_isolate) === 0) reasons.push('Auto Isolir OFF');
if (Number(inv.amount) === 0) reasons.push('Nominal Rp 0');
if (!reasons.length) continue;
const note = 'AUTO-LUNAS: ' + reasons.join(' / ') + ' (verifikasi sistem)';
const finalNotes = inv.notes ? (inv.notes + ' | ' + note) : note;
const r = db.prepare("UPDATE invoices SET status='paid', paid_at=NOW_LOCAL(), paid_by_name='Sistem (Auto)', notes=? WHERE id=? AND status='unpaid'").run(finalNotes, inv.id);
if (r.changes) items.push({ id: inv.id, amount: Number(inv.amount || 0), period: inv.period_month + '/' + inv.period_year });
}
if (items.length) {
try { db.prepare("UPDATE customers SET unpaid_streak=0, deactivate_cause='', deactivated_at=NULL WHERE id=?").run(cid); } catch (e) {}
if (opts.notify !== false) { try { notifyAutoPaidPolite(customer, items); } catch (e) {} }
}
return { count: items.length, items };
}
function notifyAutoPaidPolite(customer, items) {
try {
if (!customer || !customer.phone) return;
if (!getSetting('whatsapp_enabled', false)) return;
if (getSetting('autolunas_notify_enabled', true) === false) return;
const waSvc = require('./whatsappService');
if (!waSvc || typeof waSvc.sendWhatsAppMessage !== 'function') return;
const periods = items.map(function (it) { return it.period; }).join(', ');
const total = items.reduce(function (s, it) { return s + Number(it.amount || 0); }, 0);
const _defAl = '✅ *Tagihan LUNAS*\n\nYth. Bapak/Ibu *{{nama}}* (ID Pelanggan: {{id_pelanggan}}),\n\nTerima kasih, tagihan internet Anda telah kami tandai *LUNAS* otomatis.\n\n📅 Periode: {{rincian}}\n💰 Nominal: *Rp {{tagihan}}*\n\nTerima kasih 🙏\n— {{company}}';
const _tplAl = String(db.getAppSetting('whatsapp_autolunas_message', _defAl) || _defAl);
const msg = _tplAl
.replace(/{{nama}}/gi, customer.name || 'Pelanggan')
.replace(/{{id_pelanggan}}/gi, customer.customer_code || ('ID:' + customer.id))
.replace(/{{tagihan}}/gi, total.toLocaleString('id-ID'))
.replace(/{{total}}/gi, total.toLocaleString('id-ID'))
.replace(/{{rincian}}/gi, periods)
.replace(/{{periode}}/gi, periods)
.replace(/{{company}}/gi, getSetting('company_header', '') || '-');
waSvc.sendWhatsAppMessage(customer.phone, msg).catch(function () {});
} catch (e) {}
}
function updatePaymentInfo(invoiceId, data) {
const {
gateway, order_id, link, reference, payload, expires_at
} = data;
return db.prepare(`
UPDATE invoices SET
payment_gateway = ?,
payment_order_id = ?,
payment_link = ?,
payment_reference = ?,
payment_payload = ?,
payment_expires_at = ?
WHERE id = ?
`).run(gateway, order_id, link, reference, payload ? JSON.stringify(payload) : null, expires_at, invoiceId);
}
module.exports = {
autoLunasUnpaidForCustomer,
getInvoicesByAny,
getUnpaidInvoicesByCustomerId,
renewCustomerPrepaidValidity,
generateMonthlyInvoices, generateInvoiceForCustomer, createInstallProrataCatchUpInvoice, payInvoiceForCustomerPeriod, payInvoicesForCustomerMonths, getPaidMonthsForCustomerYear, getCustomerBillingYearSummary, getAllInvoices, getInvoiceById,
markAsPaid, markAsUnpaid, deleteInvoice,
getInvoiceSummary, getMonthlyRevenue, voidPreviousUnpaidInvoices,
getDashboardStats, getRecentPayments, getTopUnpaid,
getTodayRevenue,
updatePaymentInfo
};
+833
View File
@@ -0,0 +1,833 @@
/**
* Service: Penjadwalan Tugas Otomatis (Cron)
*/
const cron = require('node-cron');
const billingSvc = require('./billingService');
const { logger } = require('../config/logger');
const customerSvc = require('./customerService');
const mikrotikService = require('./mikrotikService');
const usageSvc = require('./usageService');
const { getSetting } = require('../config/settingsManager');
const db = require('../config/database');
const qrisUtil = require('../utils/qrisUtil');
const { formatUnpaidInvoicesSummary } = require('./whatsappService');
// 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: 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: 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;
}
async function runAutoBillingReminder() {
const enabled = getSetting('whatsapp_auto_billing_enabled', false);
const waEnabled = getSetting('whatsapp_enabled', false);
const billingEnabled = getSetting('whatsapp_billing_to_customer_enabled', true);
if (!enabled || !waEnabled || !billingEnabled) return { target: 0, sent: 0, failed: 0, skipped: 'disabled' };
const gatewayType = getSetting('wa_gateway_type', 'baileys');
const waSvc = require('./whatsappService');
if (gatewayType === 'meta') {
const phoneId = getSetting('meta_phone_number_id', '');
const token = getSetting('meta_access_token', '');
if (!phoneId || !token) {
logger.warn('[CRON] Kredensial Meta API belum diisi, pengingat tagihan otomatis dilewati.');
return;
}
} else if (gatewayType === 'fonnte') {
const token = getSetting('fonnte_token', '');
if (!token) {
logger.warn('[CRON] Token Fonnte belum diisi, pengingat tagihan otomatis dilewati.');
return;
}
} else {
// Gateway gowa/baileys/lainnya → cek koneksi gateway aktif (gateway-aware)
const waNotify = require('./waNotifyService');
const connected = await waNotify.isConnected().catch(() => false);
if (!connected) {
logger.warn('[CRON] WhatsApp gateway belum terhubung, pengingat tagihan otomatis dilewati.');
return;
}
}
const resolveBaseUrl = () => {
const explicit = String(getSetting('public_base_url', '') || '').trim();
if (explicit) return explicit.replace(/\/+$/, '');
const hostRaw = String(getSetting('server_host', 'localhost') || 'localhost').trim();
const port = Number(getSetting('server_port', 3001) || 3001);
const hasProto = /^https?:\/\//i.test(hostRaw);
const proto = port === 443 ? 'https' : 'http';
const host = hasProto ? hostRaw.replace(/\/+$/, '') : `${proto}://${hostRaw}`;
const withPort = (port === 80 || port === 443) ? host : `${host}:${port}`;
return withPort.replace(/\/+$/, '');
};
const loginLink = `${resolveBaseUrl()}/customer/login`;
const baseDelayMs = (Number(getSetting('whatsapp_broadcast_delay', 5) || 5) * 1000); // Default 5 detik
const batchSize = 15; // 15 pesan per batch (dari 20)
const batchPauseMs = 120000; // Pause 2 menit setelah batch (dari 1 menit)
const today = new Date();
const day = today.getDate();
const customers = customerSvc.getAllCustomers();
let targetCount = 0;
let sent = 0;
let failed = 0;
let batchCount = 0;
const defaultTemplate =
`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 ${getSetting('company_header', 'ISP')}`;
const _tmplKey = String(getSetting('auto_billing_template_key', 'whatsapp_auto_billing_message') || 'whatsapp_auto_billing_message');
const template = String(db.getAppSetting(_tmplKey, defaultTemplate) || defaultTemplate);
// Filter pelanggan yang perlu diingatkan
const targetCustomers = [];
const seenPhones = new Set();
for (const c of customers) {
const phone = c.phone ? String(c.phone).trim() : '';
if (!phone || phone.length < 9) continue;
let digits = phone.replace(/\D/g, '');
if (!digits) continue;
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
if (seenPhones.has(digits)) continue;
const isPrepaid = c.package_billing_type === 'prepaid';
let shouldSend = false;
if (isPrepaid) {
if (c.expired_at) {
const expDate = new Date(c.expired_at);
if (!isNaN(expDate.getTime())) {
const diffMs = expDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 1 || diffDays === 2) {
shouldSend = true;
}
}
}
} else {
const unpaidCount = Number(c.unpaid_count || 0) || 0;
if (unpaidCount > 0) {
const dueDay = Number(c.isolate_day || 0) || Number(getSetting('isolir_day', 10) || 10) || 10;
const offsets = String(getSetting('reminder_days_before', '2')).split(',').map(function (x) { return parseInt(x, 10); }).filter(function (n) { return Number.isFinite(n) && n > 0; });
shouldSend = offsets.some(function (off) { return (dueDay - off) >= 1 && day === (dueDay - off); });
}
}
if (!shouldSend) continue;
seenPhones.add(digits);
targetCustomers.push(c);
}
if (targetCustomers.length === 0) {
logger.info('[CRON] Tidak ada pelanggan yang perlu diingatkan hari ini.');
return { target: 0, sent: 0, failed: 0, skipped: 'no_targets' };
}
let _autoRunId = 0;
try { _autoRunId = Number(db.prepare("INSERT INTO automation_runs (type, template_key, isolate_day, mode, interval_label, target, sent, failed, status) VALUES ('auto_reminder',?,0,'cron',?,?,0,0,'running')").run(_tmplKey, 'H-' + String(getSetting('reminder_days_before','2')), targetCustomers.length).lastInsertRowid) || 0; } catch (e) {}
logger.info(`[CRON] Memulai pengingat tagihan otomatis untuk ${targetCustomers.length} pelanggan dengan smart rate limit.`);
// Kirim pesan dengan smart rate limit
for (let i = 0; i < targetCustomers.length; i++) {
const c = targetCustomers[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));
const unpaidInvoices = billingSvc.getUnpaidInvoicesByCustomerId(c.id);
const latestUnpaid = unpaidInvoices[0] || null;
const summary = formatUnpaidInvoicesSummary(unpaidInvoices);
const totalTagihan = summary.totalAmount > 0 ? summary.totalAmount : Number(latestUnpaid && latestUnpaid.amount || 0);
const rincianBulan = summary.periodText || (latestUnpaid ? `${latestUnpaid.period_month}/${latestUnpaid.period_year}` : '');
const displayPeriode = summary.isMultiple
? `${summary.periodText}\n\n📋 *Rincian Tunggakan:*\n${summary.breakdownText}`
: summary.periodText;
// Process Dynamic QRIS if enabled & available
let qrisImageBuffer = null;
let qrisCodeVal = 0;
let finalTagihanStr = totalTagihan.toLocaleString('id-ID');
if (unpaidInvoices.length > 0) {
try {
const inv = unpaidInvoices[0];
let code = Number(inv.qris_unique_code || 0) || 0;
let amt = Number(inv.qris_amount_unique || 0) || 0;
const invId = Number(inv.id);
const baseAmount = Number(inv.amount || 0);
// Jika amt tidak ada atau amt tidak cocok dengan akumulasi total tagihan + kode, buat/perbarui nominal unik
if ((!code || !amt || (amt - code !== baseAmount)) && invId > 0 && baseAmount > 0) {
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
const custId = Number(c?.id || inv?.customer_id || 0);
let tryCode = 0;
let tryAmt = 0;
if (code > 0) {
const pAmt = baseAmount + code;
if (!exists.get('unpaid', pAmt, invId)) {
tryCode = code;
tryAmt = pAmt;
}
}
if (!tryAmt && custId > 0) {
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
const pAmt = baseAmount + prefCode;
if (!exists.get('unpaid', pAmt, invId)) {
tryCode = prefCode;
tryAmt = pAmt;
}
}
if (!tryAmt) {
for (let randCode = 1; randCode <= 499; randCode++) {
const pAmt = baseAmount + randCode;
if (!exists.get('unpaid', pAmt, invId)) {
tryCode = randCode;
tryAmt = pAmt;
break;
}
}
}
if (tryAmt > 0) {
code = tryCode;
amt = tryAmt;
db.prepare('UPDATE invoices SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL()) WHERE id=?').run(code, amt, invId);
}
}
if (amt > 0) {
finalTagihanStr = amt.toLocaleString('id-ID');
qrisCodeVal = code;
const qrisPayload = String(getSetting('qris_static_payload', '') || '').trim();
const qrisEnabledRaw = getSetting('qris_static_enabled', true);
const qrisEnabled = !(qrisEnabledRaw === false || qrisEnabledRaw === 'false' || qrisEnabledRaw === 0 || qrisEnabledRaw === '0');
if (qrisEnabled && qrisPayload && typeof qrisUtil.buildDynamicQrisJpgBuffer === 'function') {
qrisImageBuffer = await qrisUtil.buildDynamicQrisJpgBuffer(qrisPayload, amt);
}
}
} catch (qrisErr) {
logger.warn(`[CRON] Gagal generate QRIS dinamis untuk ${c.name}: ${qrisErr.message}`);
}
}
// Format pesan dengan Spintax & variation untuk anti-spam
let formattedMsg = template
.replace(/{{nama}}/gi, c.name || 'Pelanggan')
.replace(/{{id_pelanggan}}/gi, c.customer_code || '-')
.replace(/{{username}}/gi, c.pppoe_username || '-')
.replace(/{{pppoe}}/gi, c.pppoe_username || '-')
.replace(/{{tagihan}}/gi, finalTagihanStr)
.replace(/{{rincian_detail}}/gi, summary.isMultiple ? `📋 *Rincian Tunggakan:*\n${summary.breakdownText}` : '')
.replace(/{{rincian}}/gi, displayPeriode || '-')
.replace(/{{periode}}/gi, displayPeriode || '-')
.replace(/{{paket}}/gi, c.package_name || '-')
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
.replace(/{{link}}/gi, loginLink)
.replace(/{{link_portal}}/gi, loginLink)
.replace(/{{qris_nominal}}/gi, finalTagihanStr)
.replace(/{{qris_kode}}/gi, String(qrisCodeVal || 0).padStart(3, '0'))
.replace(/{{qris_qr}}/gi, 'QRIS terlampir');
const { parseSpintax } = await import('./whatsappBot.mjs');
formattedMsg = parseSpintax(formattedMsg);
// Add subtle variation untuk menghindari spam detection
formattedMsg = addMessageVariation(formattedMsg, i);
// Jeda mengetik manusiawi sebelum kirim
await waSvc.humanTypingDelay(formattedMsg);
if (qrisImageBuffer && /qris/i.test(formattedMsg)) {
try {
const waNotifyCron = require('./waNotifyService');
const sentImg = await waNotifyCron.sendImage(c.phone, qrisImageBuffer, formattedMsg);
if (!sentImg) await waSvc.sendWhatsAppMessage(c.phone, formattedMsg);
} catch (imgErr) {
logger.warn('[CRON] Gagal kirim QRIS image, fallback teks: ' + imgErr.message);
await waSvc.sendWhatsAppMessage(c.phone, formattedMsg);
}
} else {
await waSvc.sendWhatsAppMessage(c.phone, formattedMsg);
}
const ok = true;
if (ok) {
sent++;
targetCount++;
batchCount++;
} else {
throw new Error('Gagal kirim pesan');
}
// Batch Processing: Pause setelah N pesan
if (batchCount >= batchSize && i < targetCustomers.length - 1) {
logger.info(`[CRON] Selesai batch ${Math.floor(i / batchSize) + 1} (${batchSize} pesan). Pause ${Math.floor(batchPauseMs / 1000)} detik...`);
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(`[CRON] SKIP: Error permanent untuk ${c.phone} - ${errorMsg}`);
failed++;
break; // Skip retry langsung ke pelanggan berikutnya
}
// Error temporary, bisa retry
logger.error(`[CRON] Gagal kirim ke ${c.phone} (attempt ${attemptCount}/${maxAttempts}): ${errorMsg}`);
if (attemptCount >= maxAttempts) {
logger.warn(`[CRON] Max attempts tercapai untuk ${c.phone}`);
failed++;
} else {
// Exponential backoff untuk retry
const backoffDelay = getBackoffDelay(attemptCount);
logger.info(`[CRON] Retry ke ${c.phone} dalam ${Math.floor(backoffDelay / 1000)} detik...`);
await new Promise(r => setTimeout(r, backoffDelay));
}
}
}
}
logger.info(`[CRON] Pengingat tagihan otomatis selesai: target=${targetCount}, terkirim=${sent}, gagal=${failed}`);
if (_autoRunId) { try { db.prepare("UPDATE automation_runs SET sent=?, failed=?, status='done', finished_at=(NOW_LOCAL()) WHERE id=?").run(sent, failed, _autoRunId); } catch (e) {} }
return { target: targetCount, sent: sent, failed: failed };
}
function startCronJobs() {
// 1. Generate Tagihan Otomatis setiap tanggal 1 jam 00:01
cron.schedule('1 0 1 * *', () => {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
logger.info(`[CRON] Menjalankan generate tagihan otomatis untuk ${month}/${year}`);
try {
const count = billingSvc.generateMonthlyInvoices(month, year);
logger.info(`[CRON] Berhasil generate ${count} tagihan otomatis.`);
} catch (error) {
logger.error(`[CRON] Gagal generate tagihan otomatis: ${error.message}`);
}
});
// 2. Isolir Otomatis setiap hari jam 02:00
cron.schedule('0 2 * * *', async () => {
const now = new Date();
const today = now.getDate();
logger.info(`[CRON] Menjalankan pengecekan isolir otomatis harian (Tanggal ${today})`);
let isolatedCount = 0;
const BATCH_SIZE = 100;
let lastId = 0;
while (true) {
const batch = db.prepare(
`SELECT c.*, p.billing_type AS package_billing_type
FROM customers c LEFT JOIN packages p ON c.package_id = p.id
WHERE c.status = 'active' AND c.id > ?
ORDER BY c.id ASC
LIMIT ?`
).all(lastId, BATCH_SIZE);
if (batch.length === 0) break;
for (const c of batch) {
lastId = c.id;
const isAutoIsolateEnabled = c.auto_isolate !== 0;
if (!isAutoIsolateEnabled) continue;
const isPrepaid = c.package_billing_type === 'prepaid';
if (isPrepaid) {
if (c.expired_at) {
const expDate = new Date(c.expired_at);
if (!isNaN(expDate.getTime()) && now >= expDate) {
try {
logger.info(`[CRON] Isolir otomatis PRABAYAR: ${c.name} (${c.pppoe_username || c.hotspot_username || '-'}) - Berakhir: ${c.expired_at}`);
await customerSvc.suspendCustomer(c.id);
isolatedCount++;
} catch (err) {
logger.error(`[CRON] Gagal isolir prabayar ${c.name}: ${err.message}`);
}
}
}
} else {
const customerIsolirDay = c.isolate_day || 10;
const unpaidCount = db.prepare('SELECT COUNT(*) as cnt FROM invoices WHERE customer_id=? AND status=?').get(c.id, 'unpaid')?.cnt || 0;
if (today >= customerIsolirDay && unpaidCount > 0) {
try {
logger.info(`[CRON] Isolir otomatis PASCABAYAR: ${c.name} (${c.pppoe_username || c.hotspot_username || '-'}) - Tgl Isolir: ${customerIsolirDay}`);
await customerSvc.suspendCustomer(c.id);
isolatedCount++;
} catch (err) {
logger.error(`[CRON] Gagal isolir pascabayar ${c.name}: ${err.message}`);
}
}
}
}
if (batch.length === BATCH_SIZE) {
await new Promise(r => setTimeout(r, 300)); // Jeda 300ms antar batch
}
}
logger.info(`[CRON] Selesai pengecekan isolir. Total ${isolatedCount} pelanggan baru di-isolir.`);
});
cron.schedule('0 9 * * *', () => { runAutoBillingReminder().catch(function (e) { logger.error('[CRON] Reminder error: ' + (e && e.message)); }); });
// Auto-PERINGATAN TUNGGAKAN (menuju nonaktif/hapus) — sekali per kenaikan streak
// Rekap bulanan auto-lunas (mitra) ke admin — tgl 1 jam 00:30
cron.schedule('30 0 1 * *', () => { sendAutoLunasRecap().catch(function (e) { logger.error('[CRON] recap error: ' + (e && e.message)); }); }, { timezone: getSetting('timezone', 'Asia/Jakarta') });
cron.schedule('10 9 * * *', async () => {
try {
const enabled = getSetting('arrears_notice_enabled', true) !== false && getSetting('arrears_notice_enabled', true) !== 'false';
if (!enabled || !getSetting('whatsapp_enabled', false)) return;
const noticeSvc = require('./arrearsNoticeService');
const batas = Math.max(1, Number(getSetting('auto_deactivate_months', 3) || 3));
const rows = db.prepare(`
SELECT * FROM customers
WHERE package_id IS NOT NULL
AND COALESCE(unpaid_streak,0) >= ?
AND COALESCE(unpaid_streak,0) > COALESCE(arrears_notice_streak,0)
AND phone IS NOT NULL AND phone != ''
`).all(Math.max(1, batas - 1));
if (rows.length) logger.info(`[CRON] Peringatan tunggakan: ${rows.length} pelanggan.`);
for (const c of rows) {
try {
const info = noticeSvc.info(c);
if (!info.amount) continue;
await noticeSvc.sendTo(c.phone, noticeSvc.buildArrearsMessage(c));
db.prepare('UPDATE customers SET arrears_notice_streak=COALESCE(unpaid_streak,0) WHERE id=?').run(c.id);
logger.info(`[CRON] Peringatan tunggakan terkirim: ${c.name} (streak ${c.unpaid_streak})`);
} catch (e) { logger.error(`[CRON] arrears notice "${c.name}": ${e.message}`); }
await new Promise(r => setTimeout(r, 1500));
}
} catch (e) { logger.error('[CRON] arrears notice error: ' + (e && e.message)); }
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
// 4. Jam Kalong (Night Speed) Start - Jam 00:00
cron.schedule('0 0 * * *', async () => {
logger.info('[CRON] Memulai Jam Kalong (Night Speed) - Ganti Profile...');
try {
let count = 0;
const BATCH_SIZE = 100;
let offset = 0;
while (true) {
const batch = db.prepare(
`SELECT c.id, c.name, c.pppoe_username, c.router_id, c.package_id,
p.use_night_speed, p.night_profile_name
FROM customers c
JOIN packages p ON c.package_id = p.id
WHERE c.pppoe_username IS NOT NULL AND c.pppoe_username != ''
AND p.use_night_speed = 1 AND p.night_profile_name IS NOT NULL AND p.night_profile_name != ''
LIMIT ? OFFSET ?`
).all(BATCH_SIZE, offset);
if (batch.length === 0) break;
for (const c of batch) {
try {
logger.info(`[CRON] Switching ${c.name} to Night Profile: ${c.night_profile_name}`);
await mikrotikService.setPppoeProfile(c.pppoe_username, c.night_profile_name, c.router_id);
count++;
} catch (err) {
logger.error(`[CRON] Gagal switch Jam Kalong untuk ${c.name}: ${err.message}`);
}
}
offset += BATCH_SIZE;
if (batch.length === BATCH_SIZE) await new Promise(r => setTimeout(r, 200));
}
logger.info(`[CRON] Jam Kalong aktif untuk ${count} pelanggan.`);
} catch (e) {
logger.error(`[CRON] Error Jam Kalong Start: ${e.message}`);
}
});
// 5. Jam Kalong (Night Speed) End - Jam 06:00
cron.schedule('0 6 * * *', async () => {
logger.info('[CRON] Mengakhiri Jam Kalong (Night Speed) - Kembali ke Profile Normal...');
try {
let count = 0;
const BATCH_SIZE = 100;
let offset = 0;
while (true) {
const batch = db.prepare(
`SELECT c.id, c.name, c.pppoe_username, c.router_id, c.package_id,
p.use_night_speed, p.name AS normal_profile
FROM customers c
JOIN packages p ON c.package_id = p.id
WHERE c.pppoe_username IS NOT NULL AND c.pppoe_username != ''
AND p.use_night_speed = 1
LIMIT ? OFFSET ?`
).all(BATCH_SIZE, offset);
if (batch.length === 0) break;
for (const c of batch) {
try {
logger.info(`[CRON] Restoring ${c.name} to Normal Profile: ${c.normal_profile}`);
await mikrotikService.setPppoeProfile(c.pppoe_username, c.normal_profile, c.router_id);
count++;
} catch (err) {
logger.error(`[CRON] Gagal restore profil normal untuk ${c.name}: ${err.message}`);
}
}
offset += BATCH_SIZE;
if (batch.length === BATCH_SIZE) await new Promise(r => setTimeout(r, 200));
}
logger.info(`[CRON] Profil normal dikembalikan untuk ${count} pelanggan.`);
} catch (e) {
logger.error(`[CRON] Error Jam Kalong End: ${e.message}`);
}
});
// 6. Track Usage Pelanggan (Data Traffic) - Setiap 10 Menit
cron.schedule('*/10 * * * *', async () => {
const enabled = getSetting('usage_tracking_enabled', true);
if (!enabled) return;
try {
const routers = mikrotikService.getAllRouters();
// Hanya load pelanggan yang punya pppoe_username (lebih efisien)
const customers = db.prepare(
`SELECT c.id, c.pppoe_username FROM customers c
WHERE c.pppoe_username IS NOT NULL AND c.pppoe_username != '' AND c.status = 'active'`
).all();
const customerMap = new Map();
customers.forEach(c => customerMap.set(c.pppoe_username, c));
for (const r of routers) {
try {
const actives = await mikrotikService.getPppoeActive(r.id);
for (const s of actives) {
const username = s.name;
const cust = customerMap.get(username);
if (!cust) continue;
const totalIn = parseInt(s['bytes-in']) || 0;
const totalOut = parseInt(s['bytes-out']) || 0;
const now = new Date();
const currentUsage = usageSvc.getUsage(cust.id, now.getMonth()+1, now.getFullYear());
let deltaIn = 0;
let deltaOut = 0;
if (currentUsage) {
if (totalIn < currentUsage.last_total_bytes_in || totalOut < currentUsage.last_total_bytes_out) {
deltaIn = totalIn;
deltaOut = totalOut;
} else {
deltaIn = totalIn - currentUsage.last_total_bytes_in;
deltaOut = totalOut - currentUsage.last_total_bytes_out;
}
} else {
deltaIn = totalIn;
deltaOut = totalOut;
}
if (deltaIn > 0 || deltaOut > 0) {
usageSvc.updateUsage(cust.id, deltaIn, deltaOut, totalIn, totalOut);
}
}
} catch (err) {
logger.error(`[CRON] Gagal track usage di router ${r.name}: ${err.message}`);
}
}
} catch (e) {
logger.error(`[CRON] Error Usage Tracking: ${e.message}`);
}
});
// 7. FUP (Fair Usage Policy) Check - Setiap Jam
cron.schedule('0 * * * *', async () => {
logger.info('[CRON] Mengecek FUP Pelanggan...');
try {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
const BATCH_SIZE = 100;
let offset = 0;
let fupCount = 0;
while (true) {
// Hanya ambil pelanggan yang paketnya punya FUP aktif
const batch = db.prepare(
`SELECT c.id, c.name, c.pppoe_username, c.router_id,
p.fup_limit_gb, p.fup_profile_name
FROM customers c
JOIN packages p ON c.package_id = p.id
WHERE c.pppoe_username IS NOT NULL AND c.pppoe_username != ''
AND p.use_fup = 1 AND p.fup_limit_gb > 0
AND p.fup_profile_name IS NOT NULL AND p.fup_profile_name != ''
LIMIT ? OFFSET ?`
).all(BATCH_SIZE, offset);
if (batch.length === 0) break;
for (const c of batch) {
const usage = usageSvc.getUsage(c.id, month, year);
if (!usage) continue;
const totalGB = (usage.bytes_in + usage.bytes_out) / (1024 * 1024 * 1024);
if (totalGB >= c.fup_limit_gb) {
logger.warn(`[CRON] FUP: ${c.name} (${totalGB.toFixed(2)} GB / ${c.fup_limit_gb} GB). Turunkan kecepatan...`);
try {
await mikrotikService.setPppoeProfile(c.pppoe_username, c.fup_profile_name, c.router_id);
fupCount++;
} catch (err) {
logger.error(`[CRON] Gagal apply FUP untuk ${c.name}: ${err.message}`);
}
}
}
offset += BATCH_SIZE;
if (batch.length === BATCH_SIZE) await new Promise(r => setTimeout(r, 200));
}
logger.info(`[CRON] FUP check selesai. ${fupCount} pelanggan di-throttle.`);
} catch (e) {
logger.error(`[CRON] Error FUP Check: ${e.message}`);
}
});
// 8. Auto-Refresh ACS Devices & Sync IPs - Setiap 5 Menit
cron.schedule('*/5 * * * *', async () => {
const enabled = getSetting('use_builtin_acs', false) === true || getSetting('use_builtin_acs', false) === 'true';
if (!enabled) return;
logger.info('[CRON] Menjalankan sinkronisasi dan auto-refresh ACS Devices...');
try {
const activeSessionsMap = await mikrotikService.getActivePppoeSessionsMap();
const acsDevices = db.prepare('SELECT id, ip_address, connection_request_url, params, last_inform FROM acs_devices').all();
const acsServerService = require('./acsServerService');
let triggeredCount = 0;
let ipUpdatedCount = 0;
for (const dev of acsDevices) {
let params = {};
try { params = JSON.parse(dev.params || '{}'); } catch (_) {}
// Extract PPPoE user
let pppoeUser = '';
const pppoeUserKeys = [
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.2.WANPPPConnection.1.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.3.WANPPPConnection.1.Username',
'Device.PPP.Interface.1.Username',
'VirtualParameters.pppoeUsername',
'VirtualParameters.pppUsername'
];
for (const key of pppoeUserKeys) {
if (params[key] && params[key] !== '-') {
pppoeUser = params[key];
break;
}
}
if (!pppoeUser) {
for (const key of Object.keys(params)) {
if (key.toLowerCase().includes('wanpppconnection') && key.toLowerCase().endsWith('.username') && params[key]) {
pppoeUser = params[key];
break;
}
}
}
if (!pppoeUser || pppoeUser === '-') continue;
const activeSession = activeSessionsMap.get(pppoeUser.toLowerCase());
if (activeSession) {
const currentIp = activeSession.ip;
if (currentIp && currentIp !== dev.ip_address) {
logger.info(`[ACS-Sync] IP address changed for device ${dev.id} (${pppoeUser}): ${dev.ip_address} -> ${currentIp}`);
let newCrUrl = dev.connection_request_url || '';
if (newCrUrl) {
try {
if (newCrUrl.startsWith('http')) {
const urlObj = new URL(newCrUrl);
urlObj.hostname = currentIp;
newCrUrl = urlObj.toString();
} else {
newCrUrl = newCrUrl.replace(/(https?:\/\/)([^:/]+)(.*)/, `$1${currentIp}$3`);
}
} catch (e) {
newCrUrl = newCrUrl.replace(/(https?:\/\/)([^:/]+)(.*)/, `$1${currentIp}$3`);
}
} else {
newCrUrl = `http://${currentIp}:58000/`;
}
const now = new Date().toISOString();
db.prepare('UPDATE acs_devices SET ip_address = ?, connection_request_url = ?, updated_at = ? WHERE id = ?')
.run(currentIp, newCrUrl, now, dev.id);
ipUpdatedCount++;
dev.ip_address = currentIp;
dev.connection_request_url = newCrUrl;
}
const lastInformTime = dev.last_inform ? new Date(dev.last_inform).getTime() : 0;
const isStale = (Date.now() - lastInformTime) > 15 * 60 * 1000;
if (isStale) {
logger.info(`[ACS-Sync] Device ${dev.id} (${pppoeUser}) is active on MikroTik but offline/stale in ACS. Triggering connection request to refresh data.`);
acsServerService.triggerConnectionRequest(dev.id).catch(err => {
logger.warn(`[ACS-Sync] Failed to trigger connection request for ${dev.id}: ${err.message}`);
});
triggeredCount++;
}
}
}
logger.info(`[CRON] Selesai sinkronisasi ACS. IP diperbarui: ${ipUpdatedCount}, Connection requests dipicu: ${triggeredCount}`);
} catch (e) {
logger.error(`[CRON] Error Auto-Refresh ACS: ${e.message}`);
}
});
// Worker: refresh cache status layanan & deteksi estimasi bergeser (tiap 5 menit)
cron.schedule('*/5 * * * *', () => {
try {
const noticeSvc = require('./serviceNoticeService');
const n = noticeSvc.computeNotice();
if (n.active) {
logger.info(`[CRON][Notice] mode=${n.mode} estimasi=${n.estimateText || '-'}`);
}
} catch (e) {
logger.error('[CRON][Notice] Error: ' + e.message);
}
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
// Auto-nonaktif: pelanggan menunggak >= N bulan (default 3)
cron.schedule('30 3 * * *', async () => {
try {
const enabled = getSetting('auto_deactivate_enabled', true);
if (!enabled) return;
const months = parseInt(getSetting('auto_deactivate_months', 3), 10) || 3;
const res = await customerSvc.autoDeactivateOverdueCustomers(months);
if (res.processed > 0) {
logger.info(`[CRON] Auto-nonaktif: ${res.processed}/${res.candidates} pelanggan dinonaktifkan (>= ${months} bulan menunggak).`);
}
} catch (e) {
logger.error(`[CRON] Auto-nonaktif error: ${e.message}`);
}
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
// Bersihkan sampah ACS (device DISCOVERYSERVICE + task macet/lama)
cron.schedule('15 4 * * *', () => {
try {
const r = require('./acsServerService').clearAcsJunk();
if (r.junkDevices || r.junkTasks || r.staleTasks || r.oldTasks) {
logger.info('[CRON] ACS cleanup: devices=' + r.junkDevices + ' junkTasks=' + r.junkTasks + ' staleTasks=' + r.staleTasks + ' oldTasks=' + r.oldTasks);
}
} catch (e) { logger.error('[CRON] ACS cleanup error: ' + e.message); }
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
// Otomasi retensi: hapus notif webhook lama agar daftar tidak menumpuk
cron.schedule('45 4 * * *', () => {
try {
const _en = String(getSetting('webhook_notif_retention_enabled', '1')).toLowerCase();
if (!(_en === '1' || _en === 'true' || _en === 'on')) return;
const days = Math.max(1, parseInt(getSetting('webhook_notif_retention_days', '30'), 10) || 30);
const r = db.prepare("DELETE FROM webhook_payment_notifs WHERE created_at < datetime(NOW_LOCAL(), ?)").run('-' + days + ' days');
if (r.changes) logger.info('[CRON] Retensi notif webhook: ' + r.changes + ' notif > ' + days + ' hari dihapus.');
} catch (e) { logger.error('[CRON] Retensi notif webhook error: ' + e.message); }
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
logger.info('[CRON] Semua tugas penjadwalan telah aktif.');
}
async function sendAutoLunasRecap(pMonth, pYear) {
try {
if (!getSetting('whatsapp_enabled', false)) return { sent: false, reason: 'wa_off' };
if (getSetting('autolunas_recap_enabled', true) === false) return { sent: false, reason: 'recap_off' };
const now = new Date();
const m = Number(pMonth) || (now.getMonth() + 1);
const y = Number(pYear) || now.getFullYear();
const rows = db.prepare("SELECT i.id, i.amount, c.name 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").all(m, y);
if (!rows.length) return { sent: false, count: 0, period: m + '/' + y };
const waNotify = require('./waNotifyService');
const list = rows.map(function (r) { return '• ' + r.name + ' (Rp ' + Number(r.amount || 0).toLocaleString('id-ID') + ')'; }).join('\n');
const total = rows.reduce(function (s, r) { return s + Number(r.amount || 0); }, 0);
const msg = '📋 *REKAP AUTO-LUNAS — Periode ' + m + '/' + y + '*\n\nJumlah : *' + rows.length + ' pelanggan*\nNominal : *Rp ' + total.toLocaleString('id-ID') + '*\n\nDaftar:\n' + list + '\n\nWaktu : ' + now.toLocaleString('id-ID');
const to = await waNotify.sendToStaff(msg);
logger.info('[CRON] Rekap auto-lunas ' + m + '/' + y + ' terkirim ke admin (' + rows.length + ' pelanggan).');
return { sent: true, count: rows.length, period: m + '/' + y, to: to };
} catch (e) { logger.error('[CRON] rekap auto-lunas error: ' + (e && e.message)); return { sent: false, error: e && e.message }; }
}
module.exports = { startCronJobs, runAutoBillingReminder, sendAutoLunasRecap };
+90
View File
@@ -0,0 +1,90 @@
const crypto = require('crypto');
const db = require('../config/database');
const { logger } = require('../config/logger');
// Pastikan tabel customer_auth_tokens ada
db.exec(`
CREATE TABLE IF NOT EXISTS customer_auth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
customer_id INTEGER,
login_id TEXT NOT NULL,
pppoe_username TEXT,
user_agent TEXT,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_cat_token ON customer_auth_tokens(token);
`);
/**
* Buat persistent token baru (90 hari) untuk auto-login PWA
*/
function createToken(loginId, pppoeUsername, customer, req) {
try {
const token = crypto.randomBytes(32).toString('hex');
const custId = customer ? customer.id : null;
const userAgent = req?.headers ? req.headers['user-agent'] : null;
// 90 hari masa berlaku token
const expiryDate = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
const expiresAt = expiryDate.toISOString().replace('T', ' ').slice(0, 19);
db.prepare(`
INSERT INTO customer_auth_tokens
(token, customer_id, login_id, pppoe_username, user_agent, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(token, custId, String(loginId).trim(), pppoeUsername || null, userAgent, expiresAt);
return token;
} catch (err) {
logger.error(`[AuthToken] Gagal membuat auth token: ${err.message}`);
return null;
}
}
/**
* Verifikasi token auto-login
*/
function verifyToken(token) {
if (!token || typeof token !== 'string') return null;
try {
const row = db.prepare(`
SELECT * FROM customer_auth_tokens
WHERE token = ? AND expires_at > datetime('now', 'localtime')
`).get(token.trim());
return row || null;
} catch (err) {
logger.error(`[AuthToken] Gagal verifikasi auth token: ${err.message}`);
return null;
}
}
/**
* Cabut / hapus token (saat logout)
*/
function revokeToken(token) {
if (!token) return;
try {
db.prepare('DELETE FROM customer_auth_tokens WHERE token = ?').run(token.trim());
} catch (err) {
logger.error(`[AuthToken] Gagal cabut auth token: ${err.message}`);
}
}
/**
* Bersihkan token yang sudah kadaluarsa
*/
function cleanupExpiredTokens() {
try {
db.prepare("DELETE FROM customer_auth_tokens WHERE expires_at <= datetime('now', 'localtime')").run();
} catch (err) {}
}
module.exports = {
createToken,
verifyToken,
revokeToken,
cleanupExpiredTokens
};
File diff suppressed because it is too large. Load diff
+67
View File
@@ -0,0 +1,67 @@
/**
* customerIdService.js — ID Pelanggan PERMANEN (tidak bisa di-regenerate).
*
* Format: <PREFIX>-<angka> contoh: SKY-48213
* - Hanya ANGKA setelah tanda hubung (tanpa huruf).
* - Dibuat otomatis sekali saat pelanggan dibuat, lalu melekat permanen.
* - Unik (dicek ke DB, tanpa duplikat).
* - Prefix & jumlah digit diatur di Pengaturan (customer_id_prefix / customer_id_digits).
*/
const crypto = require('crypto');
const db = require('../config/database');
const { getSetting } = require('../config/settingsManager');
const DEFAULT_PREFIX = 'ISP';
const DEFAULT_DIGITS = 5;
function sanitizePrefix(p) {
return String(p || '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 12);
}
function sanitizeDigits(d) {
const n = parseInt(d, 10);
if (!Number.isFinite(n)) return DEFAULT_DIGITS;
return Math.min(Math.max(n, 4), 10);
}
function randomDigits(n) {
let out = '';
for (let i = 0; i < n; i++) out += String(crypto.randomInt(0, 10));
return out;
}
function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
// Format baru yang berlaku: <PREFIX>-<angka> (minimal 4 digit).
// Sengaja TIDAK bergantung nilai prefix saat ini → mengubah prefix tidak
// akan membuat kode lama ikut ter-regenerate (permanen).
function isNewFormat(code) {
return /^[A-Z0-9]+-\d{4,10}$/.test(String(code || ''));
}
function generateCustomerCode() {
const prefix = sanitizePrefix(getSetting('customer_id_prefix', DEFAULT_PREFIX)) || DEFAULT_PREFIX;
const digits = sanitizeDigits(getSetting('customer_id_digits', DEFAULT_DIGITS));
const check = db.prepare('SELECT 1 FROM customers WHERE customer_code = ? LIMIT 1');
for (let i = 0; i < 60; i++) {
const code = prefix + '-' + randomDigits(digits);
if (!check.get(code)) return code;
}
// Ruang hampir penuh → tambah 1 digit agar tetap unik
return prefix + '-' + randomDigits(digits + 1);
}
// Isi yang kosong + migrasi sekali untuk format lama (bukan PREFIX-angka).
// Kode yang sudah sesuai format baru TIDAK akan diubah (permanen).
function backfillCustomerCodes() {
try {
const rows = db.prepare('SELECT id, customer_code FROM customers').all()
.filter((r) => !r.customer_code || !isNewFormat(r.customer_code));
if (!rows.length) return 0;
const upd = db.prepare('UPDATE customers SET customer_code = ? WHERE id = ?');
const run = db.transaction((list) => { for (const r of list) upd.run(generateCustomerCode(), r.id); });
run(rows);
return rows.length;
} catch (e) {
return -1;
}
}
module.exports = { generateCustomerCode, backfillCustomerCodes, isNewFormat, sanitizePrefix, sanitizeDigits };
+962
View File
@@ -0,0 +1,962 @@
/**
* Service: CRUD Pelanggan & Paket
*/
const db = require('../config/database');
const { logger } = require('../config/logger');
const { getCurrentDateInTimezone, getSetting } = require('../config/settingsManager');
const { generateCustomerCode, backfillCustomerCodes } = require('./customerIdService');
// Backfill ID Pelanggan (customer_code) yang masih kosong saat startup
try { const n = backfillCustomerCodes(); if (n > 0) logger.info('[CustomerID] Backfill ' + n + ' customer_code'); } catch (e) {}
// ─── HELPER FUNCTIONS ─────────────────────────────────────────
/**
* Get effective router_id for a customer
* Respects multi-router mode setting:
* - If mode is 'active': returns customer's router_id if set.
* For legacy single-router customers with NULL router_id, it falls back to
* explicit default_router_id or the only active router (if exactly one exists).
* - If mode is 'disabled': returns customer's router_id if set, else auto-detect
* - First tries explicit default_router_id setting
* - If not set, auto-detects first (smallest ID) available router
*/
function getEffectiveRouterId(customerRouterId) {
const mode = getSetting('multi_router_mode', 'disabled');
if (customerRouterId && customerRouterId > 0) {
return customerRouterId;
}
const explicitDefault = getSetting('default_router_id', null);
if (explicitDefault) {
const parsed = parseInt(explicitDefault, 10);
if (parsed && parsed > 0) {
return parsed;
}
}
try {
if (mode === 'active') {
// In multi-router mode, only fall back automatically if there is exactly one active router.
const routers = db.prepare('SELECT id FROM routers WHERE is_active = 1 ORDER BY id ASC LIMIT 2').all();
if (routers.length === 1 && routers[0].id > 0) {
logger.warn(`[getEffectiveRouterId] router_id pelanggan kosong saat multi-router aktif. Fallback ke satu-satunya router aktif: ${routers[0].id}`);
return routers[0].id;
}
return null;
}
// Mode: disabled (single router with fallback) - auto-detect first active router
const router = db.prepare('SELECT id FROM routers WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
if (router && router.id > 0) {
return router.id;
}
}
catch (e) {
// Ignore if routers table doesn't exist or query fails
}
return null;
}
// ─── CUSTOMERS ───────────────────────────────────────────────
function calculateExpiredAt(startDateStr, durationDays = 30) {
const d = startDateStr ? new Date(startDateStr) : new Date();
if (isNaN(d.getTime())) return null;
d.setDate(d.getDate() + (parseInt(durationDays, 10) || 30));
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day} 23:59:59`;
}
function getAllCustomers(search = '', routerId = null, filterStatus = '', filterArea = '', opts = {}) {
const now = getCurrentDateInTimezone();
const month = now.getMonth() + 1;
const year = now.getFullYear();
const base = `
SELECT c.*, p.name as package_name, p.price as package_price,
p.billing_type as package_billing_type, p.duration_days as package_duration_days,
p.promo_cycles as package_promo_cycles,
p.prorate_first_invoice as package_prorate_first_invoice,
p.speed_down, p.speed_up, p.fup_limit_gb, p.use_fup,
r.name as router_name,
o.name as olt_name,
odp.name as odp_name,
(SELECT COUNT(*) FROM invoices WHERE customer_id=c.id AND status='unpaid') as unpaid_count,
u.bytes_in, u.bytes_out
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 olts o ON c.olt_id = o.id
LEFT JOIN odps odp ON c.odp_id = odp.id
LEFT JOIN customer_usage u ON u.customer_id = c.id AND u.period_month = ${month} AND u.period_year = ${year}
`;
const whereClauses = [];
const params = [];
if (search) {
const s = `%${search}%`;
whereClauses.push(`(c.name LIKE ? OR c.phone LIKE ? OR c.nik LIKE ? OR c.genieacs_tag LIKE ? OR c.address LIKE ? OR c.area LIKE ? OR c.pppoe_username LIKE ? OR c.static_ip LIKE ? OR c.hotspot_username LIKE ? OR c.customer_code LIKE ?)`);
params.push(s, s, s, s, s, s, s, s, s, s);
}
const rId = routerId ? Number(routerId) : null;
if (rId && rId > 0) {
whereClauses.push(`c.router_id = ?`);
params.push(rId);
}
if (filterStatus) {
if (filterStatus === 'inactive') {
whereClauses.push(`c.status = ? AND COALESCE(c.reg_status,'') != 'rejected'`);
} else {
whereClauses.push(`c.status = ?`);
}
params.push(filterStatus);
}
if (filterArea) {
whereClauses.push(`LOWER(TRIM(c.area)) = LOWER(TRIM(?))`);
params.push(filterArea);
}
// ── Filter lanjutan ──
const { packageId, collectorId, oltId, connectionType, billing, autoIsolate } = opts || {};
if (packageId) { whereClauses.push('c.package_id = ?'); params.push(Number(packageId)); }
if (collectorId) { whereClauses.push('c.collector_id = ?'); params.push(Number(collectorId)); }
if (oltId) { whereClauses.push('c.olt_id = ?'); params.push(Number(oltId)); }
if (connectionType) { whereClauses.push('c.connection_type = ?'); params.push(String(connectionType)); }
if (autoIsolate === 'on') whereClauses.push('c.auto_isolate = 1');
else if (autoIsolate === 'off') whereClauses.push('(c.auto_isolate = 0 OR c.auto_isolate IS NULL)');
if (billing === 'unpaid') whereClauses.push("(SELECT COUNT(*) FROM invoices WHERE customer_id=c.id AND status='unpaid') > 0");
else if (billing === 'paid') whereClauses.push("(SELECT COUNT(*) FROM invoices WHERE customer_id=c.id AND status='unpaid') = 0");
const whereSql = whereClauses.length > 0 ? ` WHERE ` + whereClauses.join(' AND ') : '';
return db.prepare(base + whereSql + ` ORDER BY c.name ASC`).all(...params);
}
function getAllCustomerAreas() {
try {
const hasCustArea = db.prepare("PRAGMA table_info(customers)").all().some(c => c.name === 'area');
const hasColArea = db.prepare("PRAGMA table_info(collectors)").all().some(c => c.name === 'area');
const hasTechArea = db.prepare("PRAGMA table_info(technicians)").all().some(c => c.name === 'area');
const queries = [];
if (hasCustArea) queries.push("SELECT area FROM customers WHERE area IS NOT NULL AND TRIM(area) != ''");
if (hasColArea) queries.push("SELECT area FROM collectors WHERE area IS NOT NULL AND TRIM(area) != ''");
if (hasTechArea) queries.push("SELECT area FROM technicians WHERE area IS NOT NULL AND TRIM(area) != ''");
if (queries.length === 0) return [];
const unionSql = `SELECT DISTINCT TRIM(area) as area FROM (` + queries.join(' UNION ') + `) ORDER BY area ASC`;
const rows = db.prepare(unionSql).all();
return rows.map(r => r.area).filter(Boolean);
} catch (e) {
logger.warn('[CustomerService] getAllCustomerAreas error:', e.message);
return [];
}
}
function resetPromoCyclesUsed(customerId) {
const id = Number(customerId);
if (!Number.isFinite(id) || id <= 0) throw new Error('ID pelanggan tidak valid');
return db.prepare('UPDATE customers SET promo_cycles_used = 0 WHERE id=?').run(id);
}
function getCustomerById(id) {
return db.prepare(`
SELECT c.*, p.name as package_name, p.price as package_price,
p.billing_type as package_billing_type, p.duration_days as package_duration_days,
p.promo_cycles as package_promo_cycles,
p.prorate_first_invoice as package_prorate_first_invoice,
r.name as router_name, o.name as olt_name, odp.name as odp_name
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 olts o ON c.olt_id = o.id
LEFT JOIN odps odp ON c.odp_id = odp.id
WHERE c.id = ?
`).get(id);
}
function assertCustomerUnique(data, excludeId) {
const ex = excludeId ? Number(excludeId) : 0;
const phone = String(data.phone || '').trim();
const name = String(data.name || '').trim();
const pppoe = String(data.pppoe_username || '').trim();
const hotspot = String(data.hotspot_username || '').trim();
const staticIp = String(data.static_ip || '').trim();
if (phone) {
const r = db.prepare('SELECT id, name FROM customers WHERE phone = ? AND id != ? LIMIT 1').get(phone, ex);
if (r) throw new Error('Nomor HP sudah terdaftar untuk pelanggan: ' + r.name);
}
if (name) {
const r = db.prepare('SELECT id, name FROM customers WHERE lower(trim(name)) = lower(trim(?)) AND id != ? LIMIT 1').get(name, ex);
if (r) throw new Error('Nama pelanggan sudah terdaftar: ' + r.name + ' (ID ' + r.id + ')');
}
if (pppoe) {
const r = db.prepare('SELECT id, name FROM customers WHERE pppoe_username = ? AND id != ? LIMIT 1').get(pppoe, ex);
if (r) throw new Error('PPPoE Username sudah dipakai pelanggan: ' + r.name);
}
if (hotspot) {
const r = db.prepare('SELECT id, name FROM customers WHERE hotspot_username = ? AND id != ? LIMIT 1').get(hotspot, ex);
if (r) throw new Error('Hotspot Username sudah dipakai pelanggan: ' + r.name);
}
if (staticIp) {
const r = db.prepare('SELECT id, name FROM customers WHERE static_ip = ? AND id != ? LIMIT 1').get(staticIp, ex);
if (r) throw new Error('Static IP sudah dipakai pelanggan: ' + r.name);
}
}
function slugifyPppoeName(name) {
return String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 28) || 'user';
}
// Buat akun PPPoE otomatis: <nama>@<domain>, password default dari setting. Unik.
function generatePppoeAccount(customerId) {
const { getSetting } = require('../config/settingsManager');
const c = getCustomerById(customerId);
if (!c) throw new Error('Pelanggan tidak ditemukan');
const domain = String(getSetting('pppoe_domain', 'sky.id') || 'sky.id').replace(/^@/, '').trim() || 'sky.id';
const _pat = String(getSetting('pppoe_username_pattern', 'name') || 'name');
const base = slugifyPppoeName(_pat === 'customer_code' ? (c.customer_code || c.name) : c.name);
let username = base + '@' + domain;
let n = 1;
while (db.prepare('SELECT id FROM customers WHERE pppoe_username = ? AND id != ?').get(username, customerId)) {
n++; username = base + n + '@' + domain;
}
const password = String(getSetting('pppoe_default_password', 'openisp') || 'openisp');
db.prepare("UPDATE customers SET pppoe_username=?, pppoe_password=?, connection_type = CASE WHEN connection_type IS NULL OR connection_type='' THEN 'pppoe' ELSE connection_type END WHERE id=?").run(username, password, customerId);
return { username: username, password: password };
}
function createCustomer(data) {
assertCustomerUnique(data, null);
require('./odpService').validateOdpPort(data.odp_id, data.pon_port, null);
let expiredAt = data.expired_at || null;
if (!expiredAt && data.package_id) {
const pkg = db.prepare('SELECT billing_type, duration_days FROM packages WHERE id=?').get(data.package_id);
if (pkg && pkg.billing_type === 'prepaid') {
const baseDate = data.install_date || new Date().toISOString().slice(0, 10);
expiredAt = calculateExpiredAt(baseDate, pkg.duration_days || 30);
}
}
return db.prepare(`
INSERT INTO customers (customer_code, nik, name, phone, email, address, area, package_id, router_id, olt_id, odp_id, pon_port, lat, lng, genieacs_tag, pppoe_username, pppoe_password, pppoe_remote_address, isolir_profile, status, install_date, expired_at, notes, auto_isolate, isolate_day, connection_type, static_ip, mac_address, hotspot_username, hotspot_password, hotspot_profile, collector_id, is_radius)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
generateCustomerCode(),
data.nik ? String(data.nik).trim() : '',
data.name, data.phone || '', data.email || '', data.address || '',
data.area ? String(data.area).trim() : '',
data.package_id ? parseInt(data.package_id) : null,
data.router_id ? parseInt(data.router_id) : null,
data.olt_id ? parseInt(data.olt_id) : null,
data.odp_id ? parseInt(data.odp_id) : null,
data.pon_port || '',
data.lat || '',
data.lng || '',
data.genieacs_tag || '', data.pppoe_username || '',
data.pppoe_password || '',
data.pppoe_remote_address || '',
data.isolir_profile || 'isolir',
data.status || 'active',
data.install_date || null,
expiredAt,
data.notes || '',
data.auto_isolate !== undefined ? parseInt(data.auto_isolate) : 1,
data.isolate_day !== undefined ? parseInt(data.isolate_day) : 10,
data.connection_type || 'pppoe',
data.static_ip || '',
data.mac_address || '',
data.hotspot_username || '',
data.hotspot_password || '',
data.hotspot_profile || '',
data.collector_id ? parseInt(data.collector_id) : null,
data.is_radius !== undefined ? parseInt(data.is_radius) : 1
);
}
function updateCustomer(id, data) {
assertCustomerUnique(data, id);
require('./odpService').validateOdpPort(data.odp_id, data.pon_port, id);
const prev = db.prepare('SELECT package_id, expired_at, install_date, nik FROM customers WHERE id=?').get(id);
const newPkgId = data.package_id ? parseInt(data.package_id, 10) : null;
const pkgChanged = prev && Number(prev.package_id || 0) !== Number(newPkgId || 0);
let expiredAt = data.expired_at !== undefined ? data.expired_at : (prev ? prev.expired_at : null);
if (!expiredAt && newPkgId) {
const pkg = db.prepare('SELECT billing_type, duration_days FROM packages WHERE id=?').get(newPkgId);
if (pkg && pkg.billing_type === 'prepaid') {
const baseDate = data.install_date || (prev ? prev.install_date : null) || new Date().toISOString().slice(0, 10);
expiredAt = calculateExpiredAt(baseDate, pkg.duration_days || 30);
}
}
const result = db.prepare(`
UPDATE customers SET nik=?, name=?, phone=?, email=?, address=?, area=?, package_id=?, router_id=?, olt_id=?, odp_id=?, pon_port=?, lat=?, lng=?, genieacs_tag=?, pppoe_username=?, pppoe_password=?, pppoe_remote_address=?, isolir_profile=?, status=?, install_date=?, expired_at=?, notes=?, auto_isolate=?, isolate_day=?, cable_path=?, connection_type=?, static_ip=?, mac_address=?, hotspot_username=?, hotspot_password=?, hotspot_profile=?, collector_id=?, is_radius=?
WHERE id=?
`).run(
data.nik !== undefined ? (data.nik ? String(data.nik).trim() : '') : (prev.nik || ''),
data.name, data.phone || '', data.email || '', data.address || '',
data.area ? String(data.area).trim() : '',
data.package_id ? parseInt(data.package_id) : null,
data.router_id ? parseInt(data.router_id) : null,
data.olt_id ? parseInt(data.olt_id) : null,
data.odp_id ? parseInt(data.odp_id) : null,
data.pon_port || '',
data.lat || '',
data.lng || '',
data.genieacs_tag || '', data.pppoe_username || '',
data.pppoe_password || '',
data.pppoe_remote_address || '',
data.isolir_profile || 'isolir',
data.status || 'active',
data.install_date || null,
expiredAt,
data.notes || '',
data.auto_isolate !== undefined ? parseInt(data.auto_isolate) : 1,
data.isolate_day !== undefined ? parseInt(data.isolate_day) : 10,
data.cable_path || null,
data.connection_type || 'pppoe',
data.static_ip || '',
data.mac_address || '',
data.hotspot_username || '',
data.hotspot_password || '',
data.hotspot_profile || '',
data.collector_id ? parseInt(data.collector_id) : null,
data.is_radius !== undefined ? parseInt(data.is_radius) : 1,
id
);
if (pkgChanged) {
db.prepare('UPDATE customers SET promo_cycles_used = 0 WHERE id=?').run(id);
}
// Retroaktif: bila auto_isolate=0, lunaskan tagihan unpaid pelanggan ini
try {
if (data && Number(data.auto_isolate) === 0) {
const billingSvc = require('./billingService');
if (billingSvc && typeof billingSvc.autoLunasUnpaidForCustomer === 'function') {
const res = billingSvc.autoLunasUnpaidForCustomer(id, { notify: true });
if (res && res.count > 0) logger.info('[updateCustomer] Auto-lunas retroaktif ' + res.count + ' tagihan untuk "' + (data.name || id) + '"');
}
}
} catch (e) { logger.error('[updateCustomer] auto-lunas retroaktif gagal: ' + e.message); }
return result;
}
function updateCustomerCablePath(id, path) {
return db.prepare('UPDATE customers SET cable_path = ? WHERE id = ?').run(path, id);
}
async function deleteCustomer(id) {
const customer = getCustomerById(id);
const mikrotikSvc = require('./mikrotikService');
// WARNING: Check if customer has MikroTik connections without router_id
const hasMikrotikConnection = customer && (
customer.connection_type === 'pppoe' && customer.pppoe_username
|| customer.connection_type === 'static' && customer.static_ip
|| customer.connection_type === 'hotspot' && customer.hotspot_username
);
if (hasMikrotikConnection && !customer.router_id) {
logger.warn(`[deleteCustomer] Pelanggan "${customer.name}" (ID: ${id}) memiliki koneksi ${customer.connection_type} tapi router_id NULL. Akun tidak akan dihapus dari MikroTik.`);
}
// Remove static IP if connection type is static
if (customer && customer.connection_type === 'static' && customer.static_ip && customer.router_id) {
try {
await mikrotikSvc.removeStaticIp(customer.static_ip, customer.router_id);
} catch (e) {
console.error('Failed to remove static IP from MikroTik during customer deletion:', e);
}
}
// CATATAN: Sesuai permintaan, akun/secret PPPoE di MikroTik TIDAK dihapus saat pelanggan dihapus
// agar data user PPPoE di MikroTik tetap aman dan tidak ikut terhapus.
if (customer && customer.connection_type === 'pppoe' && customer.pppoe_username) {
logger.info(`[deleteCustomer] Pelanggan "${customer.name}" dihapus dari billing. Akun PPPoE "${customer.pppoe_username}" di MikroTik dipertahankan.`);
}
// Remove Hotspot user if connection type is hotspot and username exists
if (customer && customer.connection_type === 'hotspot' && customer.hotspot_username && customer.router_id) {
try {
// Get hotspot user to find the ID
const hotspotUser = await mikrotikSvc.getHotspotUserByName(customer.hotspot_username, customer.router_id);
if (hotspotUser && hotspotUser.id) {
await mikrotikSvc.deleteHotspotUser(hotspotUser.id, customer.router_id);
console.log(`Successfully removed Hotspot user ${customer.hotspot_username} from MikroTik`);
} else {
console.warn(`Hotspot user ${customer.hotspot_username} not found in MikroTik`);
}
} catch (e) {
console.error('Failed to remove Hotspot user from MikroTik during customer deletion:', e);
}
}
return db.prepare('DELETE FROM customers WHERE id=?').run(id);
}
async function bulkDeleteCustomers(ids) {
if (!Array.isArray(ids) || ids.length === 0) return 0;
let deleted = 0;
for (const id of ids) {
const numId = Number(id);
if (!Number.isFinite(numId) || numId <= 0) continue;
try {
await deleteCustomer(numId);
deleted++;
} catch (e) {
logger.error(`[bulkDeleteCustomers] Gagal menghapus pelanggan ${numId}: ${e.message}`);
}
}
return deleted;
}
function getCustomerStats() {
return {
total: db.prepare('SELECT COUNT(*) as c FROM customers').get().c,
active: db.prepare("SELECT COUNT(*) as c FROM customers WHERE status IN ('active', 'ditangguhkan')").get().c,
ditangguhkan: db.prepare("SELECT COUNT(*) as c FROM customers WHERE status='ditangguhkan'").get().c,
suspended: db.prepare("SELECT COUNT(*) as c FROM customers WHERE status='suspended'").get().c,
inactive: db.prepare("SELECT COUNT(*) as c FROM customers WHERE status='inactive' AND COALESCE(reg_status,'')!='rejected'").get().c,
};
}
// ─── PACKAGES ────────────────────────────────────────────────
function getAllPackages(routerId = null) {
const rId = routerId ? Number(routerId) : null;
try {
if (rId && rId > 0) {
return db.prepare(`
SELECT p.*, r.name as router_name, COUNT(c.id) as customer_count
FROM packages p
LEFT JOIN customers c ON c.package_id = p.id
LEFT JOIN routers r ON p.router_id = r.id
WHERE p.router_id IS NULL OR p.router_id = ?
GROUP BY p.id ORDER BY p.price ASC
`).all(rId);
}
return db.prepare(`
SELECT p.*, r.name as router_name, COUNT(c.id) as customer_count
FROM packages p
LEFT JOIN customers c ON c.package_id = p.id
LEFT JOIN routers r ON p.router_id = r.id
GROUP BY p.id ORDER BY p.price ASC
`).all();
} catch (e) {
if (String(e?.message || '').includes('no such column')) {
return db.prepare(`
SELECT p.*, NULL as router_name, COUNT(c.id) as customer_count
FROM packages p
LEFT JOIN customers c ON c.package_id = p.id
GROUP BY p.id ORDER BY p.price ASC
`).all();
}
throw e;
}
}
function getPackageById(id) {
return db.prepare('SELECT * FROM packages WHERE id=?').get(id);
}
function createPackage(data) {
const pkgName = String(data.name || '').trim();
if (!pkgName) throw new Error('Nama paket wajib diisi.');
const _dup = db.prepare('SELECT id FROM packages WHERE lower(trim(name)) = lower(trim(?))').get(pkgName);
if (_dup) throw new Error('Nama paket sudah dipakai: ' + pkgName);
const down = Math.round(parseFloat(data.speed_down || 0) * 1000);
const up = Math.round(parseFloat(data.speed_up || 0) * 1000);
if (!(down > 0) || !(up > 0)) throw new Error('Kecepatan Download & Upload wajib diisi dan > 0.');
const down_upto = Math.round(parseFloat(data.speed_down_upto || 0) * 1000);
const up_upto = Math.round(parseFloat(data.speed_up_upto || 0) * 1000);
const n_down = Math.round(parseFloat(data.night_speed_down || 0) * 1000);
const n_up = Math.round(parseFloat(data.night_speed_up || 0) * 1000);
const f_down = Math.round(parseFloat(data.fup_speed_down || 0) * 1000);
const f_limit = parseFloat(data.fup_limit_gb || 0);
const promoPrice = parsePromoPrice(data.promo_price);
const promoCycles = Math.max(0, parseInt(data.promo_cycles, 10) || 0);
const prorateFirst = data.prorate_first_invoice ? 1 : 0;
const usePpn = data.use_ppn ? 1 : 0;
const ppnPercentage = parseFloat(data.ppn_percentage || 11.0);
const useUso = data.use_uso ? 1 : 0;
const usoPercentage = parseFloat(data.uso_percentage || 1.75);
const routerId = data.router_id ? parseInt(data.router_id, 10) : null;
const billingType = (data.billing_type === 'prepaid') ? 'prepaid' : 'postpaid';
const durationDays = Math.max(1, parseInt(data.duration_days, 10) || 30);
return db.prepare(`
INSERT INTO packages (
name, price, promo_price, promo_cycles, prorate_first_invoice,
speed_down, speed_up, speed_down_upto, speed_up_upto,
use_night_speed, night_profile_name, night_speed_down, night_speed_up,
use_fup, fup_profile_name, fup_limit_gb, fup_speed_down,
description,
billing_type, duration_days,
use_ppn, ppn_percentage, use_uso, uso_percentage, router_id, is_active,
pool_name, pool_ranges, local_address, only_one, parent_queue, queue_type, isolir_profile
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
pkgName, parseInt(data.price) || 0, promoPrice, promoCycles, prorateFirst,
down, up, down_upto, up_upto,
data.use_night_speed ? 1 : 0, data.night_profile_name || null, n_down, n_up,
data.use_fup ? 1 : 0, data.fup_profile_name || null, f_limit, f_down,
data.description || '',
billingType, durationDays,
usePpn, ppnPercentage, useUso, usoPercentage, routerId, (data.is_active == '0' ? 0 : 1),
(data.pool_name || null), (data.pool_ranges || null), (data.local_address || null), (data.only_one == '0' ? 0 : 1), (data.parent_queue || null), (data.queue_type || null), (data.isolir_profile || null)
);
}
function parsePromoPrice(raw) {
if (raw === undefined || raw === null || String(raw).trim() === '') return null;
const n = parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : null;
}
function updatePackage(id, data) {
const pkgName = String(data.name || '').trim();
if (!pkgName) throw new Error('Nama paket wajib diisi.');
const _dup = db.prepare('SELECT id FROM packages WHERE lower(trim(name)) = lower(trim(?)) AND id != ?').get(pkgName, id);
if (_dup) throw new Error('Nama paket sudah dipakai: ' + pkgName);
const down = Math.round(parseFloat(data.speed_down || 0) * 1000);
const up = Math.round(parseFloat(data.speed_up || 0) * 1000);
if (!(down > 0) || !(up > 0)) throw new Error('Kecepatan Download & Upload wajib diisi dan > 0.');
const down_upto = Math.round(parseFloat(data.speed_down_upto || 0) * 1000);
const up_upto = Math.round(parseFloat(data.speed_up_upto || 0) * 1000);
const n_down = Math.round(parseFloat(data.night_speed_down || 0) * 1000);
const n_up = Math.round(parseFloat(data.night_speed_up || 0) * 1000);
const f_down = Math.round(parseFloat(data.fup_speed_down || 0) * 1000);
const f_limit = parseFloat(data.fup_limit_gb || 0);
const promoPrice = parsePromoPrice(data.promo_price);
const promoCycles = Math.max(0, parseInt(data.promo_cycles, 10) || 0);
const prorateFirst = data.prorate_first_invoice ? 1 : 0;
const usePpn = data.use_ppn ? 1 : 0;
const ppnPercentage = parseFloat(data.ppn_percentage || 11.0);
const useUso = data.use_uso ? 1 : 0;
const usoPercentage = parseFloat(data.uso_percentage || 1.75);
const routerId = data.router_id ? parseInt(data.router_id, 10) : null;
const billingType = (data.billing_type === 'prepaid') ? 'prepaid' : 'postpaid';
const durationDays = Math.max(1, parseInt(data.duration_days, 10) || 30);
return db.prepare(`
UPDATE packages
SET name=?, price=?, promo_price=?, promo_cycles=?, prorate_first_invoice=?,
speed_down=?, speed_up=?, speed_down_upto=?, speed_up_upto=?,
use_night_speed=?, night_profile_name=?, night_speed_down=?, night_speed_up=?,
use_fup=?, fup_profile_name=?, fup_limit_gb=?, fup_speed_down=?,
description=?, is_active=?,
billing_type=?, duration_days=?,
use_ppn=?, ppn_percentage=?, use_uso=?, uso_percentage=?, router_id=?,
pool_name=?, pool_ranges=?, local_address=?, only_one=?, parent_queue=?, queue_type=?, isolir_profile=?
WHERE id=?
`).run(
pkgName, parseInt(data.price) || 0, promoPrice, promoCycles, prorateFirst,
down, up, down_upto, up_upto,
data.use_night_speed ? 1 : 0, data.night_profile_name || null, n_down, n_up,
data.use_fup ? 1 : 0, data.fup_profile_name || null, f_limit, f_down,
data.description || '', data.is_active == '1' ? 1 : 0,
billingType, durationDays,
usePpn, ppnPercentage, useUso, usoPercentage, routerId,
(data.pool_name || null), (data.pool_ranges || null), (data.local_address || null), (data.only_one == '0' ? 0 : 1), (data.parent_queue || null), (data.queue_type || null), (data.isolir_profile || null),
id
);
}
function deletePackage(id) {
return db.prepare('DELETE FROM packages WHERE id=?').run(id);
}
// Resolver khusus HALAMAN LOGIN: hanya menerima ID Pelanggan (SKY-xxxxx) atau Nomor WhatsApp.
function findCustomerForLogin(val) {
if (!val) return null;
const raw = String(val).trim();
if (!raw) return null;
// 1) ID Pelanggan: PREFIX-angka (mis. SKY-48213)
if (/^[A-Za-z0-9]+-\d{4,10}$/.test(raw)) {
const byCode = db.prepare('SELECT id FROM customers WHERE customer_code = ? COLLATE NOCASE').get(raw);
return byCode ? getCustomerById(byCode.id) : null;
}
// 2) Nomor HP: hanya angka (boleh spasi/tanda hubung), minimal 10 digit
if (!/^\+?\d[\d\s.\-]*$/.test(raw)) return null;
const digits = raw.replace(/\D/g, '');
if (digits.length < 10 || digits.length > 15) return null;
const suffix = digits.slice(-9);
const byPhone = db.prepare(
"SELECT id FROM customers WHERE REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(phone,' ',''),'-',''),'(',''),')',''),'+','') LIKE ? ORDER BY id ASC LIMIT 1"
).get('%' + suffix);
return byPhone ? getCustomerById(byPhone.id) : null;
}
function findCustomerByAny(val) {
if (!val) return null;
const cleanVal = val.toString().trim();
// 1. Try Phone (Priority for Login)
const phoneDigits = cleanVal.replace(/\D/g, '');
if (phoneDigits.length >= 8) {
// Cari yang 8-10 digit terakhirnya sama (lebih akurat untuk 08 vs 62)
const suffix = phoneDigits.slice(-9);
const p1 = db.prepare('SELECT id FROM customers WHERE phone LIKE ?').get(`%${suffix}`);
if (p1) return getCustomerById(p1.id);
}
// 2. Try Customer Code / ID Pelanggan (mis. SKY-48213) — exact, case-insensitive
const byCode = db.prepare('SELECT id FROM customers WHERE customer_code = ? COLLATE NOCASE').get(cleanVal);
if (byCode) return getCustomerById(byCode.id);
// 3. Try GenieACS Tag (Exact Match)
const byTag = db.prepare('SELECT id FROM customers WHERE genieacs_tag = ?').get(cleanVal);
if (byTag) return getCustomerById(byTag.id);
// 3. Try PPPoE Username (Exact Match)
const byPppoe = db.prepare('SELECT id FROM customers WHERE pppoe_username = ?').get(cleanVal);
if (byPppoe) return getCustomerById(byPppoe.id);
// 4. Try MAC Address (Exact Match or Partial Match for ONU MAC format)
// Handle ONU MAC format like: F4B5AA-ZXHN%20F477-01FFFFFFFF011FFF23F4B5AA7D806FBA
const byMac = db.prepare('SELECT id FROM customers WHERE mac_address = ?').get(cleanVal);
if (byMac) return getCustomerById(byMac.id);
// Try partial MAC match (first part before dash for ONU format)
if (cleanVal.includes('-')) {
const macPrefix = cleanVal.split('-')[0];
if (macPrefix.length >= 6) {
const byMacPrefix = db.prepare('SELECT id FROM customers WHERE mac_address LIKE ?').get(`${macPrefix}%`);
if (byMacPrefix) return getCustomerById(byMacPrefix.id);
}
}
// 5. Try ID if numeric
if (/^\d+$/.test(cleanVal) && cleanVal.length < 8) {
const c = getCustomerById(parseInt(cleanVal));
if (c) return c;
}
return null;
}
async function suspendCustomer(id) {
const customer = getCustomerById(id);
if (!customer) throw new Error('Pelanggan tidak ditemukan');
const mikrotikSvc = require('./mikrotikService');
// Get effective router_id (respects multi-router mode setting)
const effectiveRouterId = getEffectiveRouterId(customer.router_id);
// Validate router_id is present if customer has MikroTik connection
const hasMikrotikConnection = customer.connection_type === 'pppoe' && customer.pppoe_username
|| customer.connection_type === 'static' && customer.static_ip
|| customer.connection_type === 'hotspot' && customer.hotspot_username;
if (hasMikrotikConnection && !effectiveRouterId) {
logger.warn(`[suspendCustomer] Pelanggan "${customer.name}" (ID: ${id}) memiliki koneksi ${customer.connection_type} tapi router_id NULL dan tidak ada default router. Isolir lokal hanya, MikroTik tidak diupdate.`);
updateCustomer(id, { ...customer, status: 'suspended' });
return;
}
// UPDATE MIKROTIK DULU sebelum update database status (priority: network change)
try {
if (customer.connection_type === 'static' && customer.static_ip) {
const pkg = getPackageById(customer.package_id);
let limit = '5M/5M';
if (pkg) {
const up = Number(pkg.speed_up || 0) || 0;
const down = Number(pkg.speed_down || 0) || 0;
const upMbps = up > 0 ? Math.max(1, Math.round(up / 1000)) : 5;
const downMbps = down > 0 ? Math.max(1, Math.round(down / 1000)) : 5;
limit = `${upMbps}M/${downMbps}M`;
}
await mikrotikSvc.manageStaticIp({
ip: customer.static_ip,
name: customer.name,
limit: limit,
isolate: true
}, effectiveRouterId);
} else if (customer.pppoe_username) {
const _pkgS = customer.package_id ? getPackageById(customer.package_id) : null;
const isolirProfile = (_pkgS && _pkgS.isolir_profile) || customer.isolir_profile || getSetting('mikrotik_isolir_profile', 'isolir') || 'isolir';
await mikrotikSvc.setPppoeProfile(customer.pppoe_username, isolirProfile, effectiveRouterId);
if (effectiveRouterId) {
try {
await mikrotikSvc.ensurePppProfileIsolirAddressListHook(isolirProfile, effectiveRouterId);
} catch (e) {
logger.warn(`[suspendCustomer] Hook profil isolir "${isolirProfile}" di router ${effectiveRouterId}: ${e.message}`);
}
}
} else if (customer.connection_type === 'hotspot' && customer.hotspot_username) {
await mikrotikSvc.setHotspotUserDisabled(customer.hotspot_username, true, effectiveRouterId);
}
} catch (mikrotikErr) {
logger.error(`[suspendCustomer] GAGAL ubah profil di MikroTik: ${mikrotikErr.message}. Tetap update status ke database.`);
// Continue execution - update database status despite MikroTik error (graceful degradation)
}
// Update database status SETELAH MikroTik berhasil (atau gagal tapi continue)
updateCustomer(id, { ...customer, status: 'suspended' });
// WhatsApp Notification
if (customer.phone) {
try {
const { getSetting } = require('../config/settingsManager');
if (getSetting('whatsapp_enabled', false)) {
const { sendWA, whatsappStatus } = await import('./whatsappBot.mjs');
if (true) {
const defaultIsolir = `Yth. Pelanggan {{nama}},\n\nLayanan internet Anda (Paket {{paket}}) saat ini ditangguhkan (Terisolir) karena belum melunasi tagihan sebesar *Rp {{tagihan}}*.\n\nSilakan lakukan pembayaran segera melalui portal pelanggan: {{link}}\n\nTerima kasih.`;
const template = db.getAppSetting('whatsapp_isolir_message', defaultIsolir);
// Get unpaid invoices & calculate total amount
const billingSvc = require('./billingService');
const waNotify = require('./waNotifyService'); // FIX: sebelumnya undefined (54x error di log)
const { formatUnpaidInvoicesSummary } = require('./whatsappService');
const unpaidInvoices = billingSvc.getUnpaidInvoicesByCustomerId(customer.id);
const summary = formatUnpaidInvoicesSummary(unpaidInvoices);
const totalTagihan = summary.totalAmount;
const displayPeriode = summary.isMultiple
? `${summary.periodText}\n\n📋 *Rincian Tunggakan:*\n${summary.breakdownText}`
: summary.periodText;
// Generate Login Link
const explicitBaseUrl = String(getSetting('public_base_url', '') || '').trim();
let baseUrl = explicitBaseUrl.replace(/\/+$/, '');
if (!baseUrl) {
const hostRaw = String(getSetting('server_host', 'localhost') || 'localhost').trim();
const port = Number(getSetting('server_port', 3001) || 3001);
const proto = port === 443 ? 'https' : 'http';
const host = /^https?:\/\//i.test(hostRaw) ? hostRaw.replace(/\/+$/, '') : `${proto}://${hostRaw}`;
baseUrl = (port === 80 || port === 443) ? host : `${host}:${port}`;
}
const loginLink = `${baseUrl}/customer/login`;
let formattedMsg = template
.replace(/{{nama}}/gi, customer.name || 'Pelanggan')
.replace(/{{id_pelanggan}}/gi, customer.customer_code || '-')
.replace(/{{username}}/gi, customer.pppoe_username || '-')
.replace(/{{pppoe}}/gi, customer.pppoe_username || '-')
.replace(/{{paket}}/gi, customer.package_name || '-')
.replace(/{{tagihan}}/gi, summary.totalAmountStr || totalTagihan.toLocaleString('id-ID'))
.replace(/{{rincian_detail}}/gi, summary.isMultiple ? `📋 *Rincian Tunggakan:*\n${summary.breakdownText}` : '')
.replace(/{{rincian}}/gi, displayPeriode || '-')
.replace(/{{periode}}/gi, displayPeriode || '-')
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
.replace(/{{link}}/gi, loginLink);
if (summary.isMultiple && !template.includes('{{periode}}') && !template.includes('{{rincian}}')) {
const extraInfo = `\n\n📌 *Periode Tunggakan:* ${summary.periodText}\n📋 *Rincian Tunggakan:*\n${summary.breakdownText}`;
if (formattedMsg.includes('Silakan lakukan pembayaran')) {
formattedMsg = formattedMsg.replace('Silakan lakukan pembayaran', extraInfo + '\n\nSilakan lakukan pembayaran');
} else {
formattedMsg += extraInfo;
}
}
await waNotify.sendText(customer.phone, formattedMsg);
}
}
} catch (waErr) {
logger.error(`[suspendCustomer] Gagal kirim notif WhatsApp isolir: ${waErr.message}`);
}
}
return true;
}
function _handleUnsuspendInvoice(id, customer, wasSuspended, targetStatus) {
if (wasSuspended && (targetStatus === 'active' || targetStatus === 'ditangguhkan') && customer.package_id) {
try {
const now = new Date();
const curMonth = now.getMonth() + 1;
const curYear = now.getFullYear();
const existingCurInv = db.prepare('SELECT id FROM invoices WHERE customer_id = ? AND period_month = ? AND period_year = ? LIMIT 1').get(id, curMonth, curYear);
if (!existingCurInv) {
const billingSvc = require('./billingService');
billingSvc.generateInvoiceForCustomer(id, curMonth, curYear);
logger.info(`[activateCustomer] Pelanggan "${customer.name}" (ID: ${id}) dibuka dari isolir (status: ${targetStatus}). Tagihan periode berjalan ${curMonth}/${curYear} otomatis diterbitkan.`);
}
} catch (invErr) {
logger.error(`[activateCustomer] Gagal generate tagihan periode berjalan untuk pelanggan "${customer.name}" (ID: ${id}): ${invErr.message}`);
}
}
}
async function activateCustomer(id, targetStatus = 'active') {
const customer = getCustomerById(id);
if (!customer) throw new Error('Pelanggan tidak ditemukan');
const wasSuspended = (customer.status === 'suspended' || customer.status === 'isolated');
// Get effective router_id (respects multi-router mode setting)
const effectiveRouterId = getEffectiveRouterId(customer.router_id);
// Validate router_id is present if customer has MikroTik connection
const hasMikrotikConnection = customer.connection_type === 'pppoe' && customer.pppoe_username
|| customer.connection_type === 'static' && customer.static_ip
|| customer.connection_type === 'hotspot' && customer.hotspot_username;
if (hasMikrotikConnection && !effectiveRouterId) {
logger.warn(`[activateCustomer] Pelanggan "${customer.name}" (ID: ${id}) memiliki koneksi ${customer.connection_type} tapi router_id NULL dan tidak ada default router. Aktivasi lokal hanya, MikroTik tidak diupdate.`);
updateCustomer(id, { ...customer, status: targetStatus });
_handleUnsuspendInvoice(id, customer, wasSuspended, targetStatus);
return;
}
const mikrotikSvc = require('./mikrotikService');
// UPDATE MIKROTIK DULU sebelum update database status (priority: network change)
try {
if (customer.connection_type === 'static' && customer.static_ip) {
const pkg = getPackageById(customer.package_id);
let limit = '5M/5M';
if (pkg) {
const up = Number(pkg.speed_up || 0) || 0;
const down = Number(pkg.speed_down || 0) || 0;
const upMbps = up > 0 ? Math.max(1, Math.round(up / 1000)) : 5;
const downMbps = down > 0 ? Math.max(1, Math.round(down / 1000)) : 5;
limit = `${upMbps}M/${downMbps}M`;
}
await mikrotikSvc.manageStaticIp({
ip: customer.static_ip,
name: customer.name,
limit: limit,
isolate: false
}, effectiveRouterId);
} else if (customer.pppoe_username) {
const pkg = getPackageById(customer.package_id);
const targetProfile = pkg ? pkg.name : 'default';
// Validasi profile tidak kosong
if (!targetProfile || String(targetProfile).trim() === '') {
logger.warn(`[activateCustomer] Profile kosong untuk PPPoE user "${customer.pppoe_username}". Gunakan profile 'default'.`);
}
await mikrotikSvc.setPppoeProfile(customer.pppoe_username, targetProfile, effectiveRouterId);
} else if (customer.connection_type === 'hotspot' && customer.hotspot_username) {
const pkg = getPackageById(customer.package_id);
const targetProfile = String(customer.hotspot_profile || '').trim() || (pkg ? pkg.name : '');
await mikrotikSvc.upsertHotspotUser({
username: String(customer.hotspot_username || '').trim(),
password: String(customer.hotspot_password || '').trim(),
profile: targetProfile,
macAddress: String(customer.mac_address || '').trim(),
disabled: false
}, effectiveRouterId);
}
} catch (mikrotikErr) {
logger.error(`[activateCustomer] GAGAL ubah profil di MikroTik: ${mikrotikErr.message}. Tetap update status ke database.`);
// Continue execution - update database status despite MikroTik error (graceful degradation)
}
// Update database status SETELAH MikroTik berhasil (atau gagal tapi continue)
updateCustomer(id, { ...customer, status: targetStatus });
_handleUnsuspendInvoice(id, customer, wasSuspended, targetStatus);
return true;
}
/**
* Top-up / Deposit or Adjust Customer Balance manually by Admin/Cashier
* @param {number|string} customerId
* @param {number} amount - positive number
* @param {string} note - memo or payment source
* @param {string} actorName - admin or cashier name
* @param {'add'|'deduct'} actionType - 'add' or 'deduct'
* @param {boolean} sendWhatsApp - whether to send WA notification
*/
async function autoDeactivateOverdueCustomers(months = 3, actor = null) {
const m = Math.max(1, parseInt(months, 10) || 3);
const waNotify = require('./waNotifyService');
const waOn = getSetting('whatsapp_enabled', false);
const delRaw = getSetting('auto_delete_overdue_enabled', true);
const delEnabled = !(delRaw === false || delRaw === 'false');
// ── 1) Nonaktifkan pelanggan AKTIF yang menunggak >= m bulan BERTURUT-TURUT ──
const rows = db.prepare(`
SELECT id, name, phone, unpaid_streak FROM customers
WHERE status = 'active' AND package_id IS NOT NULL AND COALESCE(unpaid_streak, 0) >= ?
`).all(m);
let processed = 0;
let failed = 0;
const names = [];
for (const c of rows) {
try {
await suspendCustomer(c.id);
db.prepare("UPDATE customers SET deactivated_at = NOW_LOCAL(), deactivate_cause = 'auto_unpaid' WHERE id = ?").run(c.id);
processed++;
names.push(c.name);
logger.info(`[AutoDeactivate] "${c.name}" (ID ${c.id}) dinonaktifkan otomatis (menunggak ${c.unpaid_streak} bulan).`);
if (waOn && c.phone) {
try {
{
const _def = '⚠️ *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\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 _tpl = String(db.getAppSetting('whatsapp_deactivated_message', _def) || _def);
const _msg = _tpl.replace(/{{nama}}/gi, c.name || 'Pelanggan').replace(/{{id_pelanggan}}/gi, c.customer_code || ('ID:' + c.id)).replace(/{{bulan_nunggak}}/gi, String(c.unpaid_streak || 0)).replace(/{{batas}}/gi, String(getSetting('auto_deactivate_months', 3) || 3)).replace(/{{link}}/gi, String(getSetting('public_base_url', '') || '').replace(/\/+$/, '') + '/customer').replace(/{{company}}/gi, getSetting('company_header', '') || '-');
await waNotify.sendText(c.phone, _msg);
}
} catch (e) {}
}
} catch (e) {
failed++;
logger.error(`[AutoDeactivate] Gagal menonaktifkan "${c.name}" (ID ${c.id}): ${e.message}`);
}
}
// ── 2) Hapus pelanggan yang menunggak LEBIH dari m bulan (bulan ke-(m+1)) ──
const removed = [];
if (delEnabled) {
const overdue = db.prepare(`
SELECT id, name, phone, unpaid_streak FROM customers
WHERE package_id IS NOT NULL AND COALESCE(unpaid_streak, 0) >= ?
`).all(m + 1);
for (const c of overdue) {
try {
logger.warn(`[AutoDelete] Menghapus pelanggan "${c.name}" (ID ${c.id}) — menunggak ${c.unpaid_streak} bulan berturut-turut (> ${m}).`);
// Void dulu tagihan unpaid agar tidak menjadi 'piutang yatim'
try { db.prepare("UPDATE invoices SET status='void', voided_at=NOW_LOCAL(), void_reason='Auto-hapus: menunggak > ? bulan' WHERE customer_id=? AND status='unpaid'").run(m, c.id); } catch (e) {}
if (waOn && c.phone) { try { const _def = '🗑️ *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 _tpl = String(db.getAppSetting('whatsapp_deleted_message', _def) || _def); const _msg = _tpl.replace(/{{nama}}/gi, c.name || 'Pelanggan').replace(/{{id_pelanggan}}/gi, c.customer_code || ('ID:' + c.id)).replace(/{{bulan_nunggak}}/gi, String(c.unpaid_streak || 0)).replace(/{{company}}/gi, getSetting('company_header','') || '-'); await waNotify.sendText(c.phone, _msg); } catch (e) {} }
await deleteCustomer(c.id);
removed.push(c.name);
} catch (e) {
logger.error(`[AutoDelete] Gagal menghapus "${c.name}" (ID ${c.id}): ${e.message}`);
}
}
}
// ── 3) Ringkasan ke admin ──
if (waOn && (processed > 0 || removed.length > 0)) {
try {
const parts = [];
if (processed) parts.push(`${processed} pelanggan NONAKTIF (menunggak ${m} bln)`);
if (removed.length) parts.push(`${removed.length} pelanggan DIHAPUS (menunggak > ${m} bln)`);
await waNotify.sendToStaff(`📋 *Auto-Tunggakan*
${parts.join('; ')}.
Nonaktif: ${names.join(', ') || '-'}
Dihapus: ${removed.join(', ') || '-'}`);
} catch (e) {}
}
return { candidates: rows.length, processed, failed, names, removed };
}
module.exports = {
getAllCustomers, getAllCustomerAreas, getCustomerById, createCustomer, updateCustomer, deleteCustomer, bulkDeleteCustomers, getCustomerStats,
getAllPackages, getPackageById, createPackage, updatePackage, deletePackage,
suspendCustomer, activateCustomer, autoDeactivateOverdueCustomers, generatePppoeAccount, findCustomerByAny, findCustomerForLogin, updateCustomerCablePath,
resetPromoCyclesUsed, getEffectiveRouterId
};
+211
View File
@@ -0,0 +1,211 @@
/**
* Service: System Diagnostics & Troubleshooting
* Melakukan pengecekan terhadap dependensi eksternal dan diagnosa masalah
*/
const { logger } = require('../config/logger');
const db = require('../config/database');
const mikrotikService = require('./mikrotikService');
const axios = require('axios');
const fs = require('fs');
const path = require('path');
/**
* Perform a full system dependency check
*/
async function checkDependencies() {
const results = {
mikrotik: [],
genieacs: { status: 'unknown', message: '' },
whatsapp: { status: 'unknown', message: '' },
paymentGateways: [],
timestamp: new Date().toISOString()
};
// 1. Check MikroTik Routers
try {
const routers = db.prepare('SELECT * FROM routers').all();
for (const r of routers) {
try {
// Simple connectivity check (get identity or similar)
const isOnline = await mikrotikService.checkConnection(r.id);
results.mikrotik.push({
name: r.name,
host: r.host,
status: isOnline ? 'online' : 'offline',
error: isOnline ? null : 'Connection failed'
});
} catch (err) {
results.mikrotik.push({
name: r.name,
host: r.host,
status: 'offline',
error: err.message
});
}
}
} catch (err) {
logger.error(`[Diagnostics] MikroTik check failed: ${err.message}`);
}
// 2. Check GenieACS
try {
const { getSetting } = require('../config/settingsManager');
const { isBuiltinAcsEnabled } = require('../config/genieacs');
if (isBuiltinAcsEnabled()) {
const count = db.prepare('SELECT COUNT(*) as c FROM acs_devices').get();
results.genieacs = {
status: 'online',
message: `Built-in ACS active (${count.c} devices)`
};
} else {
const acsUrl = getSetting('genieacs_url', 'http://localhost:7557');
const username = getSetting('genieacs_username', '');
const password = getSetting('genieacs_password', '');
// Try to get devices list to verify GenieACS is working
const devicesUrl = `${acsUrl}/devices?limit=1`;
const config = {
timeout: 5000,
validateStatus: (status) => status < 500 // Accept any status < 500
};
if (username && password) {
config.auth = { username, password };
}
const response = await axios.get(devicesUrl, config);
// If we get a response (even 401), GenieACS is online
if (response.status === 200 || response.status === 401) {
results.genieacs = {
status: 'online',
message: response.status === 200 ? 'GenieACS is responding' : 'GenieACS online (auth required)'
};
} else {
results.genieacs = {
status: 'warning',
message: `GenieACS responding with status ${response.status}`
};
}
}
} catch (err) {
// Check if it's a connection error or timeout
if (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT') {
results.genieacs = {
status: 'offline',
message: 'GenieACS unreachable (connection refused)'
};
} else {
results.genieacs = {
status: 'offline',
message: `GenieACS error: ${err.message}`
};
}
}
// 3. Check WhatsApp Gateway (using whatsappStatus from whatsappBot)
try {
// Import whatsappStatus dynamically to get real-time status
const whatsappBotModule = await import('./whatsappBot.mjs');
const waStatus = whatsappBotModule.whatsappStatus;
// Check connection status
// Possible values: 'connecting', 'qr', 'open', 'loggedOut', 'close'
const isOnline = waStatus.connection === 'open';
const isQR = waStatus.connection === 'qr';
const isLoggedOut = waStatus.connection === 'loggedOut';
let message = 'WhatsApp is disconnected';
if (isOnline) {
const phone = waStatus.user?.id ? String(waStatus.user.id).split(':')[0] : 'Unknown';
message = `WhatsApp connected (${phone})`;
} else if (isQR) {
message = 'WhatsApp waiting for QR scan';
} else if (isLoggedOut) {
message = 'WhatsApp logged out - scan QR again';
} else {
message = `WhatsApp ${waStatus.connection || 'disconnected'}`;
}
results.whatsapp = {
status: isOnline ? 'online' : 'offline',
message: message
};
} catch (err) {
results.whatsapp = {
status: 'offline',
message: `WhatsApp check error: ${err.message}`
};
}
return results;
}
/**
* Get recent errors from log file
*/
function getRecentErrors(limit = 10) {
try {
const logPath = path.join(__dirname, '../logs/error.log');
if (!fs.existsSync(logPath)) return [];
const content = fs.readFileSync(logPath, 'utf8');
const lines = content.split('\n').filter(line => line.trim() !== '');
return lines.slice(-limit).reverse();
} catch (err) {
return [`Error reading log: ${err.message}`];
}
}
/**
* Comprehensive Customer Diagnostics
*/
async function diagnoseCustomer(customerId) {
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(customerId);
if (!customer) throw new Error('Customer not found');
const report = {
customer: { name: customer.name, pppoe: customer.pppoe_username },
billing: { status: 'clean', unpaidCount: 0 },
mikrotik: { status: 'unknown', details: null },
genieacs: { status: 'unknown', signal: null },
timestamp: new Date().toISOString()
};
// 1. Billing Check
const unpaid = db.prepare("SELECT COUNT(*) as count FROM invoices WHERE customer_id = ? AND status = 'unpaid'").get(customerId);
report.billing.unpaidCount = unpaid.count;
if (unpaid.count > 0) report.billing.status = 'warning';
// 2. MikroTik Check
if (customer.pppoe_username && customer.router_id) {
try {
const active = await mikrotikService.getPppoeActive(customer.router_id);
const session = active.find(s => s.name === customer.pppoe_username);
if (session) {
report.mikrotik = {
status: 'online',
details: {
uptime: session.uptime,
address: session.address,
caller_id: session['caller-id']
}
};
} else {
report.mikrotik.status = 'offline';
}
} catch (err) {
report.mikrotik.status = 'error';
report.mikrotik.error = err.message;
}
}
return report;
}
module.exports = {
checkDependencies,
getRecentErrors,
diagnoseCustomer
};
+162
View File
@@ -0,0 +1,162 @@
'use strict';
/**
* Service: Diskon tagihan (batch & individual) + riwayat.
* - Diskon persen dihitung dari HARGA DASAR (sebelum PPN/USO).
* - amount tetap nominal akhir (net) agar alur QRIS/cash/laporan ikut otomatis.
* - Saat nominal berubah, kode unik QRIS direset agar digenerate ulang.
*/
const db = require('../config/database');
const auditTrail = require('./auditTrailService');
// Ambil total PPN + USO dari catatan AUTO invoice
function parseTaxFromNotes(notes) {
const s = String(notes || '');
let tax = 0;
const grab = (re) => {
const m = re.exec(s);
if (m) return Number(String(m[1]).replace(/\./g, '')) || 0;
return 0;
};
tax += grab(/PPN\s+[\d.,]+%\s*\(Rp\s*([\d.]+)\)/i);
tax += grab(/USO\s+[\d.,]+%\s*\(Rp\s*([\d.]+)\)/i);
return tax;
}
// Harga dasar = nominal awal (sebelum diskon) dikurangi PPN+USO
function getBaseAmount(inv) {
const original = Number(inv.amount_before_discount || 0) > 0
? Number(inv.amount_before_discount)
: Number(inv.amount || 0);
const tax = parseTaxFromNotes(inv.notes);
return Math.max(0, original - tax);
}
function applyDiscountToInvoice(invoiceId, opts = {}, actor = null) {
const invId = Number(invoiceId);
if (!invId) throw new Error('ID tagihan tidak valid');
const inv = db.prepare('SELECT * FROM invoices WHERE id=?').get(invId);
if (!inv) throw new Error('Tagihan tidak ditemukan');
if (inv.status === 'paid') throw new Error('Tagihan sudah lunas, tidak bisa didiskon');
if (inv.status === 'void') throw new Error('Tagihan sudah dihanguskan');
const type = opts.type === 'percent' ? 'percent' : 'amount';
const value = Math.round(Number(opts.value) || 0);
if (value <= 0) throw new Error('Nilai diskon harus lebih dari 0');
if (type === 'percent' && value > 100) throw new Error('Diskon persen maksimal 100%');
const original = Number(inv.amount_before_discount || 0) > 0
? Number(inv.amount_before_discount)
: Number(inv.amount || 0);
const base = getBaseAmount(inv);
let discountAmount = type === 'percent'
? Math.round(base * (value / 100))
: value;
discountAmount = Math.max(0, Math.min(discountAmount, original));
const newAmount = Math.max(0, original - discountAmount);
const res = db.prepare(`
UPDATE invoices SET
amount = ?,
amount_before_discount = ?,
discount_type = ?,
discount_value = ?,
discount_amount = ?,
qris_unique_code = NULL, qris_amount_unique = NULL, qris_assigned_at = NULL
WHERE id = ? AND status = 'unpaid'
`).run(newAmount, original, type, value, discountAmount, invId);
if (!res.changes) throw new Error('Gagal menerapkan diskon (status tagihan berubah)');
const actorName = (actor && (actor.name || actor.username)) || 'Admin';
try {
db.prepare(`
INSERT INTO discount_logs
(invoice_id, customer_id, period_month, period_year, discount_type, discount_value,
discount_amount, amount_before, amount_after, applied_by, note)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
`).run(invId, inv.customer_id, inv.period_month, inv.period_year, type, value,
discountAmount, original, newAmount, actorName, String(opts.note || ''));
} catch (e) { /* log opsional */ }
if (actor) {
try {
auditTrail.logAuditTrail({
action: 'APPLY_INVOICE_DISCOUNT',
entity_type: 'invoice',
entity_id: String(invId),
actor_type: actor.type || 'admin',
actor_id: actor.id || null,
actor_name: actorName,
details: {
customer_id: inv.customer_id,
period: `${inv.period_month}/${inv.period_year}`,
type, value, discount_amount: discountAmount,
amount_before: original, amount_after: newAmount
},
ip_address: actor.ip || null,
user_agent: actor.userAgent || null
});
} catch (e) {}
}
return { invoiceId: invId, customerId: inv.customer_id, periodMonth: inv.period_month, periodYear: inv.period_year, type, value, discountAmount, amountBefore: original, amountAfter: newAmount };
}
/**
* Terapkan diskon ke banyak pelanggan untuk periode tertentu.
* opts: { customerIds, all, periodMonth, periodYear, type, value, note, actor }
*/
function applyDiscountBatch(opts = {}) {
const pMonth = Number(opts.periodMonth) || 0;
const pYear = Number(opts.periodYear) || 0;
if (!pMonth || pMonth < 1 || pMonth > 12) throw new Error('Bulan periode tidak valid');
if (!pYear) throw new Error('Tahun periode tidak valid');
let ids = Array.isArray(opts.customerIds) ? opts.customerIds.map(Number).filter(n => n > 0) : [];
if (opts.all) {
ids = db.prepare("SELECT id FROM customers WHERE status IN ('active','ditangguhkan') AND package_id IS NOT NULL").all().map(r => r.id);
}
ids = [...new Set(ids)];
if (!ids.length) throw new Error('Tidak ada pelanggan yang dipilih');
const findInv = db.prepare("SELECT id FROM invoices WHERE customer_id=? AND period_month=? AND period_year=? AND status='unpaid' LIMIT 1");
const summary = { applied: [], skipped: [], totalDiscount: 0, totalCustomers: ids.length };
for (const cid of ids) {
const row = findInv.get(cid, pMonth, pYear);
if (!row) { summary.skipped.push(cid); continue; }
try {
const r = applyDiscountToInvoice(row.id, { type: opts.type, value: opts.value, note: opts.note }, opts.actor);
summary.applied.push(r);
summary.totalDiscount += r.discountAmount;
} catch (e) {
summary.skipped.push(cid);
}
}
return summary;
}
function getDiscountLogs(limit = 100) {
return db.prepare(`
SELECT dl.*, c.name as customer_name, c.customer_code, c.phone as customer_phone
FROM discount_logs dl
LEFT JOIN customers c ON c.id = dl.customer_id
ORDER BY dl.id DESC
LIMIT ?
`).all(Math.max(1, Math.min(500, parseInt(limit, 10) || 100)));
}
function getInvoiceDiscount(invoiceId) {
return db.prepare('SELECT amount, amount_before_discount, discount_type, discount_value, discount_amount FROM invoices WHERE id=?').get(Number(invoiceId));
}
module.exports = {
parseTaxFromNotes,
getBaseAmount,
applyDiscountToInvoice,
applyDiscountBatch,
getDiscountLogs,
getInvoiceDiscount
};
+137
View File
@@ -0,0 +1,137 @@
/**
* Service: PDF Laporan Keuangan (PDFKit, A4).
* Mode: 'before' (Sebelum Pajak) | 'after' (Sesudah Pajak).
*/
const PDFDocument = require('pdfkit');
const { resolveLogoPath } = require('./pdfBranding');
const path = require('path');
const fs = require('fs');
const MNS = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
const fmtRp = (n) => 'Rp ' + Math.round(Number(n) || 0).toLocaleString('id-ID');
function generateFinanceReportPdfBuffer(o) {
const { company = 'OPEN-ISP', settings = {}, filterYear, filterMonth = 0, finance = {}, ply = {}, pm = {}, monthlyFinance = [], mode = 'after' } = o;
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({ size: 'A4', margin: 0, info: { Title: `Laporan Keuangan ${filterYear}`, Author: company, Subject: 'Laporan Laba / Rugi' } });
const bufs = [];
doc.on('data', b => bufs.push(b));
doc.on('end', () => resolve(Buffer.concat(bufs)));
doc.on('error', reject);
const INK = '#0f172a', MUTED = '#64748b', LINE = '#e2e8f0', SOFT = '#f8fafc';
const GREEN = '#16a34a', RED = '#e11d48', AMBER = '#d97706', BLUE = '#0284c7';
const LIME = '#ADFF2F';
const LEFT = 40, RIGHT = 555, W = RIGHT - LEFT;
const modeLabel = (mode === 'before') ? 'SEBELUM PAJAK' : 'SESUDAH PAJAK';
const periodStr = filterMonth ? `${MNS[filterMonth - 1]} ${filterYear}` : `Tahun ${filterYear}`;
const companyPhone = settings.company_phone || (settings.whatsapp_admin_numbers && settings.whatsapp_admin_numbers[0]) || '-';
const companyAddress = settings.company_address || 'Pusat Layanan Internet';
// ── Header band ──
doc.rect(0, 0, 595.28, 74).fill(INK);
doc.rect(0, 74, 595.28, 3).fill(LIME);
let y = 20;
const logoPath = resolveLogoPath(settings);
let nx = LEFT;
if (logoPath && fs.existsSync(logoPath)) { try { doc.image(logoPath, LEFT, y, { fit: [110, 34] }); nx = LEFT + 122; } catch (e) {} }
doc.fillColor('#ffffff').fontSize(15).font('Helvetica-Bold').text(company, nx, y, { width: 205, lineBreak: false });
doc.fillColor('#94a3b8').fontSize(8).font('Helvetica').text(companyAddress, nx, y + 19, { width: 205, height: 11, ellipsis: true });
doc.fillColor('#ffffff').fontSize(17).font('Helvetica-Bold').text('LAPORAN KEUANGAN', LEFT, y, { width: W, align: 'right' });
doc.fillColor(LIME).fontSize(9).font('Helvetica-Bold').text(periodStr + ' • ' + modeLabel, LEFT, y + 23, { width: W, align: 'right' });
// ── Meta ──
y = 90;
doc.fillColor(MUTED).fontSize(8).font('Helvetica').text(`Dicetak: ${new Date().toLocaleString('id-ID')}`, LEFT, y);
doc.fillColor(MUTED).fontSize(8).font('Helvetica').text(`Telp/WA: ${companyPhone}`, LEFT, y, { width: W, align: 'right' });
const statRow = (yy, label, value, opt = {}) => {
const { bold = false, color = null, hi = false } = opt;
const h = hi ? 22 : (bold ? 19 : 17);
if (hi) doc.roundedRect(LEFT, yy - 2, W, h, 6).fill('#ecfdf5');
doc.fillColor(hi ? '#065f46' : (bold ? INK : MUTED)).fontSize(hi ? 10.5 : (bold ? 10 : 9)).font(hi || bold ? 'Helvetica-Bold' : 'Helvetica').text(label, LEFT + 8, yy + 2, { width: W - 170 });
doc.fillColor(hi ? '#065f46' : (color || INK)).fontSize(hi ? 11 : (bold ? 10 : 9)).font(hi || bold ? 'Helvetica-Bold' : 'Helvetica').text(value, LEFT + W - 160, yy + 2, { width: 152, align: 'right' });
return yy + h;
};
// ── A. Laporan Laba/Rugi ──
y += 18;
doc.fillColor(INK).fontSize(11).font('Helvetica-Bold').text('A. LAPORAN LABA / RUGI', LEFT, y);
doc.moveTo(LEFT, y + 15).lineTo(RIGHT, y + 15).strokeColor(LINE).stroke();
y += 22;
y = statRow(y, 'Pemasukan (Bruto)', fmtRp(ply.bruto));
y = statRow(y, `DPP — Dasar Pengenaan Pajak`, fmtRp(ply.dpp));
y = statRow(y, `PPN Keluaran (${finance.ppn}%) — disetor, bukan pendapatan`, fmtRp(ply.ppn), { color: BLUE });
y = statRow(y, 'Pengeluaran', '- ' + fmtRp(ply.keluar), { color: RED });
y = statRow(y, `USO (${finance.uso}%)`, '- ' + fmtRp(ply.uso), { color: AMBER });
y = statRow(y, `BHP (${finance.bhp}%)`, '- ' + fmtRp(ply.bhp), { color: AMBER });
y = statRow(y, 'Laba Bersih Sebelum Pajak', fmtRp(ply.sebelumPph), { bold: true });
y = statRow(y, `PPh Badan (${finance.pph}%)`, '- ' + fmtRp(ply.pph), { color: RED });
y = statRow(y, 'Laba Bersih Sesudah Pajak', fmtRp(ply.bersih), { hi: true });
// headline box sesuai mode
y += 8;
const headline = (mode === 'before') ? ply.sebelumPph : ply.bersih;
const headlineLabel = (mode === 'before') ? 'LABA BERSIH SEBELUM PAJAK' : 'LABA BERSIH SESUDAH PAJAK';
doc.roundedRect(LEFT, y, W, 30, 8).fill(INK);
doc.fillColor('#ffffff').fontSize(10).font('Helvetica-Bold').text(headlineLabel, LEFT + 12, y + 10);
doc.fillColor(LIME).fontSize(13).font('Helvetica-Bold').text(fmtRp(headline), LEFT, y + 8, { width: W - 14, align: 'right' });
y += 42;
// ── B. Rekap per bulan ──
doc.fillColor(INK).fontSize(11).font('Helvetica-Bold').text('B. REKAP PER BULAN', LEFT, y);
doc.moveTo(LEFT, y + 15).lineTo(RIGHT, y + 15).strokeColor(LINE).stroke();
y += 22;
const cols = [
{ k: 'month', t: 'Bulan', w: 78, a: 'left' },
{ k: 'bruto', t: 'Pemasukan', w: 92, a: 'right' },
{ k: 'keluar', t: 'Pengeluaran', w: 92, a: 'right' },
{ k: 'sebelumPph', t: 'Laba Sblm Pajak', w: 100, a: 'right' },
{ k: 'pph', t: 'PPh Badan', w: 78, a: 'right' },
{ k: 'bersih', t: 'Laba Sstlh Pajak', w: 105, a: 'right' },
];
let cx = LEFT;
doc.rect(LEFT, y, W, 20).fill('#eef2f6');
cols.forEach(c => { doc.fillColor(INK).fontSize(7.5).font('Helvetica-Bold').text(c.t, cx + 4, y + 6, { width: c.w - 8, align: c.a }); cx += c.w; });
y += 20;
const tot = { bruto: 0, keluar: 0, sebelumPph: 0, pph: 0, bersih: 0 };
let zi = 0;
monthlyFinance.forEach(r => {
tot.bruto += r.bruto; tot.keluar += r.keluar; tot.sebelumPph += r.sebelumPph; tot.pph += r.pph; tot.bersih += r.bersih;
if (zi % 2 === 1) doc.rect(LEFT, y, W, 16).fill(SOFT);
cx = LEFT;
const cells = { month: MNS[r.month - 1], bruto: fmtRp(r.bruto), keluar: fmtRp(r.keluar), sebelumPph: fmtRp(Math.round(r.sebelumPph)), pph: fmtRp(Math.round(r.pph)), bersih: fmtRp(Math.round(r.bersih)) };
cols.forEach(c => { doc.fillColor(c.k === 'bersih' ? GREEN : INK).fontSize(7.5).font(c.k === 'month' ? 'Helvetica-Bold' : 'Helvetica').text(cells[c.k], cx + 4, y + 4, { width: c.w - 8, align: c.a }); cx += c.w; });
y += 16; zi++;
});
doc.rect(LEFT, y, W, 18).fill('#e2e8f0');
cx = LEFT;
const totCells = { month: 'TOTAL', bruto: fmtRp(Math.round(tot.bruto)), keluar: fmtRp(Math.round(tot.keluar)), sebelumPph: fmtRp(Math.round(tot.sebelumPph)), pph: fmtRp(Math.round(tot.pph)), bersih: fmtRp(Math.round(tot.bersih)) };
cols.forEach(c => { doc.fillColor(INK).fontSize(7.5).font('Helvetica-Bold').text(totCells[c.k], cx + 4, y + 5, { width: c.w - 8, align: c.a }); cx += c.w; });
y += 26;
// catatan
y += 2;
doc.roundedRect(LEFT, y, W, 34, 6).fillAndStroke('#fefce8', '#fde68a');
doc.fillColor('#92400e').fontSize(7.5).font('Helvetica').text(
`Nilai PPh Badan dihitung ${finance.pph}% x Laba Sebelum Pajak (estimasi otomatis). PPN ${finance.ppn}% bersifat pass-through (disetor, bukan pendapatan). Nilai USO ${finance.uso}% & BHP ${finance.bhp}% mengikuti setelan.`,
LEFT + 10, y + 7, { width: W - 20 });
// ── Footer ──
const fy = 780;
doc.moveTo(LEFT, fy - 12).lineTo(RIGHT, fy - 12).strokeColor(LINE).stroke();
doc.fillColor(INK).fontSize(9).font('Helvetica-Bold').text(company, LEFT, fy);
doc.fillColor(MUTED).fontSize(8).font('Helvetica').text(companyAddress, LEFT, fy + 13, { width: 330 });
doc.fillColor(MUTED).fontSize(7.5).text(`Dokumen dibuat otomatis • ${new Date().toLocaleString('id-ID')}`, LEFT, fy + 32);
doc.fillColor(MUTED).fontSize(8).font('Helvetica').text('Dibuat oleh,', 440, fy, { width: 115, align: 'center' });
doc.moveTo(420, fy + 30).lineTo(555, fy + 30).strokeColor(INK).stroke();
doc.fillColor(INK).fontSize(9).font('Helvetica-Bold').text(settings.company_manager || 'Bagian Keuangan', 420, fy + 34, { width: 135, align: 'center' });
doc.end();
} catch (err) { reject(err); }
});
}
module.exports = { generateFinanceReportPdfBuffer };
+191
View File
@@ -0,0 +1,191 @@
const { logger } = require('../config/logger');
const { getSetting } = require('../config/settingsManager');
const db = require('../config/database');
/**
* Service untuk Fonnte WhatsApp API Gateway (Cloud & Self-Hosted)
*/
/**
* Format nomor telepon ke format standar (misal 08123456789 atau 628123456789)
*/
function normalizePhone(phone) {
let cleaned = String(phone || '').replace(/\D/g, '');
if (!cleaned) return '';
if (cleaned.startsWith('0')) {
cleaned = '62' + cleaned.slice(1);
}
return cleaned;
}
/**
* Kirim Pesan Teks via Fonnte API
* @param {string} toPhone Nomor tujuan
* @param {string} messageText Teks pesan WhatsApp
* @param {object} options Opsi tambahan (delay, schedule, typing, dll)
*/
async function sendFonnteMessage(toPhone, messageText, options = {}) {
const phone = normalizePhone(toPhone);
if (!phone) throw new Error('Nomor tujuan WhatsApp tidak valid.');
const token = getSetting('fonnte_token', '');
if (!token) {
throw new Error('Token Fonnte belum dikonfigurasi. Harap isi API Token Fonnte di menu Pengaturan WhatsApp.');
}
const apiUrl = getSetting('fonnte_api_url', 'https://api.fonnte.com/send').trim() || 'https://api.fonnte.com/send';
const countryCode = getSetting('fonnte_country_code', '62').trim() || '62';
const payload = {
target: phone,
message: String(messageText || ''),
countryCode: countryCode
};
if (options.url) {
payload.url = options.url;
}
if (options.filename) {
payload.filename = options.filename;
}
if (options.schedule) {
payload.schedule = options.schedule;
}
if (options.delay) {
payload.delay = options.delay;
}
if (options.inboxid) {
payload.inboxid = options.inboxid;
}
logger.info(`[Fonnte WA] Mengirim pesan ke ${phone} via ${apiUrl}`);
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': token,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const resData = await response.json().catch(() => null);
if (!response.ok || (resData && resData.status === false)) {
const errMsg = resData?.reason || resData?.message || `HTTP ${response.status}: ${response.statusText}`;
logger.error(`[Fonnte WA Error] Gagal kirim ke ${phone}: ${errMsg}`);
throw new Error(`Fonnte API Error: ${errMsg}`);
}
logger.info(`[Fonnte WA Sukses] Pesan terkirim ke ${phone}. Response: ${JSON.stringify(resData)}`);
// Log ke tabel wa_chat_messages
try {
let custName = 'Pelanggan';
let custId = null;
const cust = db.prepare('SELECT id, name FROM customers WHERE phone LIKE ? OR phone LIKE ?').get(`%${phone.slice(-8)}%`, `%${phone}%`);
if (cust) {
custName = cust.name;
custId = cust.id;
}
const msgId = resData?.id?.[0] || resData?.id || '';
db.prepare(`
INSERT INTO wa_chat_messages
(direction, gateway, sender_phone, recipient_phone, customer_id, customer_name, message_text, status, meta_message_id)
VALUES ('outbound', 'fonnte', 'fonnte_gateway', ?, ?, ?, ?, 'sent', ?)
`).run(phone, custId, custName, messageText, String(msgId));
} catch (dbErr) {
logger.error('[Fonnte WA DB Log Error]', dbErr.message);
}
return { success: true, data: resData };
} catch (err) {
logger.error(`[Fonnte WA Exception] ${err.message}`);
throw err;
}
}
/**
* Test Koneksi / Kirim Pesan Uji Coba Fonnte
*/
async function testFonnteConnection(targetPhone, customMessage) {
const msg = customMessage || '🔔 *Tes Koneksi Fonnte WhatsApp Gateway Berhasil!*\n\nSistem Billing RTRW Net siap mengirimkan notifikasi otomatis melalui gateway Fonnte.';
return await sendFonnteMessage(targetPhone, msg);
}
/**
* Memproses Webhook Event Masuk dari Fonnte (Inbound Message & Bot Auto-Reply)
* @param {object} data Payload dari Fonnte webhook
*/
async function processFonnteWebhook(data = {}) {
const rawSender = data.sender || data.from || '';
const messageText = String(data.message || data.text || '').trim();
const senderName = data.name || 'Pelanggan';
const inboxid = data.inboxid || null;
if (!rawSender || !messageText) {
return { ok: false, reason: 'Sender atau message kosong' };
}
const phone = normalizePhone(rawSender);
// 1. Simpan pesan masuk ke database wa_chat_messages untuk Live Chat
let customerName = senderName;
let customerId = null;
try {
const cust = db.prepare('SELECT id, name FROM customers WHERE phone LIKE ? OR phone LIKE ?').get(`%${phone.slice(-8)}%`, `%${phone}%`);
if (cust) {
customerName = cust.name;
customerId = cust.id;
}
} catch (e) {}
try {
db.prepare(`
INSERT INTO wa_chat_messages
(direction, gateway, sender_phone, recipient_phone, customer_id, customer_name, message_text, status, meta_message_id)
VALUES ('inbound', 'fonnte', ?, ?, ?, ?, ?, 'read', ?)
`).run(phone, getSetting('company_phone', 'fonnte_bot'), customerId, customerName, messageText, String(data.id || inboxid || ''));
} catch (dbErr) {
logger.error('[Fonnte WA Webhook DB Error]', dbErr.message);
}
// 2. Cek apakah WhatsApp aktif di pengaturan
const waEnabled = getSetting('whatsapp_enabled', false);
if (!waEnabled) {
logger.info('[Fonnte Bot] WhatsApp dinonaktifkan di pengaturan, lewati bot auto-reply.');
return { ok: true, handled: false };
}
// Fungsi reply otomatis via Fonnte API Gateway
const reply = async (replyMsg) => {
logger.info(`[Fonnte Bot Reply] Mengirim balasan ke ${phone}: "${replyMsg.substring(0, 60)}..."`);
return await sendFonnteMessage(phone, replyMsg, { inboxid });
};
try {
const botMod = await import('./whatsappBot.mjs');
if (typeof botMod.processIncomingCommand === 'function') {
const handled = await botMod.processIncomingCommand({
senderPhone: phone,
text: messageText,
reply: reply,
senderName: customerName
});
return { ok: true, handled: !!handled };
}
} catch (botErr) {
logger.error('[Fonnte Bot Error] Gagal memproses perintah:', botErr.message);
}
return { ok: true, handled: false };
}
module.exports = {
normalizePhone,
sendFonnteMessage,
testFonnteConnection,
processFonnteWebhook
};
+188
View File
@@ -0,0 +1,188 @@
/**
* gowaWhatsappService.js — Adapter WhatsApp Gateway GOWA
* (go-whatsapp-web-multidevice — https://github.com/aldinokemal/go-whatsapp-web-multidevice)
*
* Ringan (single binary Go). REST API (default port 3000):
* POST /send/message { phone, message }
* POST /send/image multipart (phone, caption, image)
* POST /send/document multipart (phone, caption, document, filename)
* GET /devices[/:id/status]
* Auth opsional: HTTP Basic (APP_BASIC_AUTH). Device: header X-Device-Id.
*/
const { getSetting } = require('../config/settingsManager');
const { logger } = require('../config/logger');
function baseUrl() {
return String(getSetting('gowa_base_url', 'http://127.0.0.1:3000') || '').trim().replace(/\/+$/, '') || 'http://127.0.0.1:3000';
}
function authHeader() {
const u = String(getSetting('gowa_username', '') || '').trim();
const p = String(getSetting('gowa_password', '') || '').trim();
if (u && p) return 'Basic ' + Buffer.from(u + ':' + p).toString('base64');
const raw = String(getSetting('gowa_basic_auth', '') || '').trim();
if (raw) return 'Basic ' + Buffer.from(raw).toString('base64');
return null;
}
function deviceHeader(override) {
const v = (override !== undefined) ? override : getSetting('gowa_device_id', '');
return String(v || '').trim();
}
function commonHeaders(extra, devOverride) {
const h = Object.assign({}, extra || {});
const auth = authHeader(); if (auth) h.Authorization = auth;
const dev = deviceHeader(devOverride); if (dev) h['X-Device-Id'] = dev;
return h;
}
// Device ID OPSIONAL: dipakai bila device >1 (atau cocok dgn yg diset).
// Bila cuma 1 device -> otomatis dipakai, tidak wajib diisi. Hasil di-cache 60s.
let _devCache = { id: null, at: 0 };
function invalidateDeviceCache() { _devCache = { id: null, at: 0 }; }
async function resolveDeviceId(force) {
const configured = deviceHeader();
if (!force && _devCache.at && (Date.now() - _devCache.at) < 60000) return _devCache.id;
let list = [];
try { list = await listDevices(); } catch (e) { list = []; }
const has = function (id) { return list.some(function (d) { return String(d.id) === id; }); };
let chosen;
if (configured && has(configured)) chosen = configured; // device id diset & ada -> pakai itu
else if (list.length === 1) chosen = String(list[0].id); // cuma 1 device -> otomatis
else if (list.length > 1) chosen = configured || String(list[0].id); // >1 device -> pakai yg diset / pertama
else chosen = configured; // GOWA tak beri list -> serahkan ke GOWA
_devCache = { id: chosen, at: Date.now() };
return chosen;
}
function normalizePhoneJid(phone) {
let d = String(phone || '').replace(/\D/g, '');
if (!d) return '';
if (d.startsWith('0')) d = '62' + d.slice(1);
else if (d.startsWith('8')) d = '62' + d;
return d + '@s.whatsapp.net';
}
async function postJson(path, body) {
let dev = await resolveDeviceId();
let res = await fetch(baseUrl() + path, {
method: 'POST',
headers: commonHeaders({ 'Content-Type': 'application/json' }, dev),
body: JSON.stringify(body)
});
let text = await res.text();
// auto-recovery: device id lama/tidak valid -> refresh device & coba sekali lagi
if (!res.ok && /device/i.test(text) && dev) {
invalidateDeviceCache();
dev = await resolveDeviceId(true);
res = await fetch(baseUrl() + path, {
method: 'POST',
headers: commonHeaders({ 'Content-Type': 'application/json' }, dev),
body: JSON.stringify(body)
});
text = await res.text();
}
let json = null; try { json = JSON.parse(text); } catch (e) {}
if (!res.ok) throw new Error('GOWA HTTP ' + res.status + ': ' + String(text).slice(0, 200));
return json;
}
function buildForm(fields) {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) {
if (v === undefined || v === null) continue;
if (v && v.__file) fd.append(k, new Blob([v.buffer], { type: v.mimetype || 'application/octet-stream' }), v.filename || 'file');
else fd.append(k, String(v));
}
return fd;
}
async function postForm(path, fields) {
let dev = await resolveDeviceId();
let res = await fetch(baseUrl() + path, { method: 'POST', headers: commonHeaders(undefined, dev), body: buildForm(fields) });
let text = await res.text();
if (!res.ok && /device/i.test(text) && dev) {
invalidateDeviceCache();
dev = await resolveDeviceId(true);
res = await fetch(baseUrl() + path, { method: 'POST', headers: commonHeaders(undefined, dev), body: buildForm(fields) });
text = await res.text();
}
let json = null; try { json = JSON.parse(text); } catch (e) {}
if (!res.ok) throw new Error('GOWA HTTP ' + res.status + ': ' + String(text).slice(0, 200));
return json;
}
async function sendGowaMessage(toPhone, messageText, options = {}) {
const json = await postJson('/send/message', { phone: normalizePhoneJid(toPhone), message: String(messageText || '') });
return { success: true, gateway: 'gowa', id: (json && json.results && json.results.message_id) || null, raw: json };
}
async function sendGowaImage(toPhone, imageBuffer, caption = '', options = {}) {
const buf = Buffer.isBuffer(imageBuffer) ? imageBuffer : Buffer.from(imageBuffer || []);
if (!buf.length) throw new Error('Buffer gambar kosong');
const json = await postForm('/send/image', {
phone: normalizePhoneJid(toPhone),
caption: String(caption || ''),
image: { __file: true, buffer: buf, mimetype: options.mimetype || 'image/jpeg', filename: options.filename || 'image.jpg' }
});
return { success: true, gateway: 'gowa', raw: json };
}
async function sendGowaDocument(toPhone, documentBuffer, filename = 'file.pdf', caption = '', mimetype = 'application/pdf', options = {}) {
const buf = Buffer.isBuffer(documentBuffer) ? documentBuffer : Buffer.from(documentBuffer || []);
if (!buf.length) throw new Error('Buffer dokumen kosong');
const json = await postForm('/send/file', {
phone: normalizePhoneJid(toPhone),
caption: String(caption || ''),
filename: filename || 'file.pdf',
file: { __file: true, buffer: buf, mimetype: mimetype || 'application/pdf', filename: filename || 'file.pdf' }
});
return { success: true, gateway: 'gowa', raw: json };
}
async function testGowaConnection(phone, message) {
return await sendGowaMessage(phone, message || 'Tes koneksi GOWA berhasil ✅');
}
async function gowaStatus() {
try {
const dev = deviceHeader();
const url = baseUrl() + (dev ? '/devices/' + encodeURIComponent(dev) + '/status' : '/devices');
const res = await fetch(url, { headers: commonHeaders() });
const json = await res.json().catch(() => null);
return { ok: res.ok, status: res.status, data: json };
} catch (e) { return { ok: false, error: e.message }; }
}
async function listDevices() {
try {
const res = await fetch(baseUrl() + '/devices', { headers: commonHeaders() });
const json = await res.json().catch(function () { return null; });
return (json && Array.isArray(json.results)) ? json.results : [];
} catch (e) { return []; }
}
async function createDevice() {
const res = await fetch(baseUrl() + '/devices', { method: 'POST', headers: commonHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
const json = await res.json().catch(function () { return null; });
return (json && json.results) ? json.results : null;
}
let _lastQr = { link: null, at: 0 };
async function ensureDevice() {
const list = await listDevices();
const configured = deviceHeader();
if (configured && list.some(function (d) { return String(d.id) === configured; })) return configured;
if (list.length) return String(list[0].id);
const created = await createDevice().catch(function () { return null; });
return created && created.id ? String(created.id) : (configured || null);
}
async function getLoginQr(deviceId) {
const dev = deviceId || deviceHeader();
const url = baseUrl() + '/app/login' + (dev ? ('?device_id=' + encodeURIComponent(dev)) : '');
const res = await fetch(url, { headers: commonHeaders() });
const json = await res.json().catch(function () { return null; });
const r = (json && json.results) || {};
const link = r.qr_link || r.image_path || null;
if (link) _lastQr = { link: link, at: Date.now() };
return { ok: !!link, qr_link: link, duration: r.qr_duration || null, device_id: r.device_id || dev || null, code: (json && json.code) || null };
}
async function getLastQrImage() {
if (!_lastQr.link) return null;
try {
const res = await fetch(_lastQr.link, { headers: commonHeaders() });
if (!res.ok) return null;
const buf = Buffer.from(await res.arrayBuffer());
return { buffer: buf, contentType: res.headers.get('content-type') || 'image/png' };
} catch (e) { return null; }
}
module.exports = { sendGowaMessage, sendGowaImage, sendGowaDocument, testGowaConnection, gowaStatus, listDevices, ensureDevice, getLoginQr, getLastQrImage, normalizePhoneJid, baseUrl, resolveDeviceId, invalidateDeviceCache };
+41
View File
@@ -0,0 +1,41 @@
'use strict';
/**
* iconService.js — Generate ikon PWA & apple-touch-icon dari logo (pakai sharp).
* Dipanggil saat upload logo / tombol "Generate Ikon PWA".
*/
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
const ICON_DIR = path.join(__dirname, '..', 'public', 'img', 'icons');
const IMG_DIR = path.join(__dirname, '..', 'public', 'img');
const BG = { r: 10, g: 13, b: 20, alpha: 1 }; // #0A0D14
async function writeIcon(srcBuf, size, outPath, opts) {
const maskable = !!(opts && opts.maskable);
const bg = (opts && opts.bg) || (maskable ? BG : { r: 0, g: 0, b: 0, alpha: 0 });
const pad = maskable ? Math.round(size * 0.12) : 0; // safe zone maskable
const inner = Math.max(1, size - pad * 2);
const logo = await sharp(srcBuf)
.resize(inner, inner, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer();
await sharp({ create: { width: size, height: size, channels: 4, background: bg } })
.composite([{ input: logo, top: pad, left: pad }])
.png()
.toFile(outPath);
}
async function generatePwaIcons(srcBuf) {
if (!srcBuf || !srcBuf.length) throw new Error('Sumber logo kosong');
if (!fs.existsSync(ICON_DIR)) fs.mkdirSync(ICON_DIR, { recursive: true });
await writeIcon(srcBuf, 192, path.join(ICON_DIR, 'icon-192.png'), {});
await writeIcon(srcBuf, 512, path.join(ICON_DIR, 'icon-512.png'), {});
await writeIcon(srcBuf, 192, path.join(ICON_DIR, 'icon-maskable-192.png'), { maskable: true });
await writeIcon(srcBuf, 512, path.join(ICON_DIR, 'icon-maskable-512.png'), { maskable: true });
await writeIcon(srcBuf, 180, path.join(ICON_DIR, 'apple-touch-icon.png'), { bg: BG });
await writeIcon(srcBuf, 180, path.join(IMG_DIR, 'apple-touch-icon.png'), { bg: BG });
return { ok: true };
}
module.exports = { generatePwaIcons };
+61
View File
@@ -0,0 +1,61 @@
'use strict';
/**
* imageOptimize.js — Konversi otomatis gambar upload (PNG/JPEG) ke WebP agar lebih ringan.
* Memakai sharp. File yang sudah WebP/GIF tidak dikonversi (GIF dibiarkan agar animasi utuh).
*/
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
const CONVERTIBLE = new Set(['.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.tif', '.avif']);
function isConvertible(p) {
return CONVERTIBLE.has(String(path.extname(p || '') || '').toLowerCase());
}
async function webpBuffer(input, opts = {}) {
const maxWidth = Number(opts.maxWidth) || 1600;
const quality = Number(opts.quality) || 82;
let s = sharp(input, { failOn: 'none' }).rotate();
if (maxWidth > 0) s = s.resize({ width: maxWidth, withoutEnlargement: true });
return s.webp({ quality, effort: 4 }).toBuffer();
}
/**
* Konversi file upload PNG/JPEG -> WebP pada folder yang sama.
* @returns { urlPath, before, after } | null
*/
async function convertUploadToWebp(absPath, urlPrefix, opts = {}) {
try {
if (!absPath || !fs.existsSync(absPath) || !isConvertible(absPath)) return null;
const before = fs.statSync(absPath).size;
const buf = await webpBuffer(absPath, opts);
const outAbs = absPath.replace(/\.[^.]+$/, '.webp');
fs.writeFileSync(outAbs, buf);
if (outAbs !== absPath) { try { fs.unlinkSync(absPath); } catch (e) {} }
const urlPath = String(urlPrefix || '').replace(/\/+$/, '') + '/' + path.basename(outAbs);
return { urlPath, before, after: buf.length };
} catch (e) {
return null;
}
}
/**
* Tulis 2 varian sekaligus (WebP + JPEG fallback) ke `outBaseAbs` (tanpa ekstensi).
*/
async function writeWebpAndJpg(input, outBaseAbs, opts = {}) {
const maxWidth = Number(opts.maxWidth) || 1600;
const quality = Number(opts.quality) || 82;
const buf = Buffer.isBuffer(input) ? input : fs.readFileSync(input);
const mk = (fmt) => {
let s = sharp(buf, { failOn: 'none' }).rotate();
if (maxWidth > 0) s = s.resize({ width: maxWidth, withoutEnlargement: true });
return fmt === 'webp' ? s.webp({ quality, effort: 4 }).toBuffer() : s.jpeg({ quality, mozjpeg: true }).toBuffer();
};
const [webp, jpg] = await Promise.all([mk('webp'), mk('jpg')]);
fs.writeFileSync(outBaseAbs + '.webp', webp);
fs.writeFileSync(outBaseAbs + '.jpg', jpg);
return { webp: webp.length, jpg: jpg.length };
}
module.exports = { convertUploadToWebp, writeWebpAndJpg, webpBuffer, isConvertible };
+171
View File
@@ -0,0 +1,171 @@
/**
* Service: Manajemen Inventaris / Gudang (Warehouse)
*/
const db = require('../config/database');
const { logger } = require('../config/logger');
// ─── CATEGORIES ───────────────────────────────────────────────────────────
function getAllCategories() {
return db.prepare('SELECT * FROM inventory_categories ORDER BY name ASC').all();
}
function createCategory(data) {
const { name, description } = data;
return db.prepare('INSERT INTO inventory_categories (name, description) VALUES (?, ?)').run(name, description);
}
function updateCategory(id, data) {
const { name, description } = data;
return db.prepare('UPDATE inventory_categories SET name = ?, description = ? WHERE id = ?').run(name, description, id);
}
function deleteCategory(id) {
return db.prepare('DELETE FROM inventory_categories WHERE id = ?').run(id);
}
// ─── ITEMS ───────────────────────────────────────────────────────────────
function getAllItems(search = '') {
let query = `
SELECT i.*, c.name as category_name,
(SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'available') as stock_available,
(SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'assigned') as stock_assigned
FROM inventory_items i
LEFT JOIN inventory_categories c ON i.category_id = c.id
`;
const params = [];
if (search) {
query += ' WHERE i.name LIKE ? OR i.brand LIKE ? OR i.model LIKE ?';
const s = `%${search}%`;
params.push(s, s, s);
}
query += ' ORDER BY i.name ASC';
return db.prepare(query).all(...params);
}
function getItemById(id) {
return db.prepare(`
SELECT i.*, c.name as category_name
FROM inventory_items i
LEFT JOIN inventory_categories c ON i.category_id = c.id
WHERE i.id = ?
`).get(id);
}
function createItem(data) {
const { category_id, name, brand, model, unit, min_stock, description } = data;
return db.prepare(`
INSERT INTO inventory_items (category_id, name, brand, model, unit, min_stock, description)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(category_id, name, brand, model, unit, min_stock, description);
}
function updateItem(id, data) {
const { category_id, name, brand, model, unit, min_stock, description } = data;
return db.prepare(`
UPDATE inventory_items
SET category_id = ?, name = ?, brand = ?, model = ?, unit = ?, min_stock = ?, description = ?
WHERE id = ?
`).run(category_id, name, brand, model, unit, min_stock, description, id);
}
function deleteItem(id) {
return db.prepare('DELETE FROM inventory_items WHERE id = ?').run(id);
}
// ─── STOCK ───────────────────────────────────────────────────────────────
function getStockByItem(itemId) {
return db.prepare(`
SELECT s.*, cust.name as customer_name
FROM inventory_stock s
LEFT JOIN customers cust ON s.assigned_to_customer_id = cust.id
WHERE s.item_id = ?
ORDER BY s.created_at DESC
`).all(itemId);
}
function addStock(data, actor = 'Admin') {
const { item_id, serial_number, quantity, condition, location, note } = data;
const run = db.transaction(() => {
const result = db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, note)
VALUES (?, ?, ?, ?, ?, ?)
`).run(item_id, serial_number || null, quantity, condition || 'new', location || 'Gudang Utama', note || '');
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
VALUES (?, ?, 'in', ?, ?, ?)
`).run(item_id, result.lastInsertRowid, quantity, actor, note || 'Stock Masuk');
return result;
});
return run();
}
function assignStockToCustomer(stockId, customerId, actor = 'Admin', note = '') {
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId);
if (!stock) throw new Error('Stock tidak ditemukan');
if (stock.status === 'assigned') throw new Error('Stock sudah terpasang di pelanggan lain');
const run = db.transaction(() => {
db.prepare(`
UPDATE inventory_stock
SET status = 'assigned', assigned_to_customer_id = ?, updated_at = (NOW_LOCAL())
WHERE id = ?
`).run(customerId, stockId);
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
VALUES (?, ?, 'out', ?, ?, ?)
`).run(stock.item_id, stockId, stock.quantity, actor, note || `Terpasang ke pelanggan ID: ${customerId}`);
});
return run();
}
function adjustStock(stockId, newQuantity, note, actor = 'Admin') {
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId);
if (!stock) throw new Error('Stock tidak ditemukan');
const run = db.transaction(() => {
const diff = newQuantity - stock.quantity;
db.prepare('UPDATE inventory_stock SET quantity = ?, updated_at = (NOW_LOCAL()) WHERE id = ?').run(newQuantity, stockId);
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
VALUES (?, ?, 'adjust', ?, ?, ?)
`).run(stock.item_id, stockId, diff, actor, note || 'Penyesuaian Stock');
});
return run();
}
function getInventoryLogs(limit = 100) {
return db.prepare(`
SELECT l.*, i.name as item_name, s.serial_number
FROM inventory_logs l
LEFT JOIN inventory_items i ON l.item_id = i.id
LEFT JOIN inventory_stock s ON l.stock_id = s.id
ORDER BY l.created_at DESC
LIMIT ?
`).all(limit);
}
function getLowStockItems() {
return db.prepare(`
SELECT i.*, c.name as category_name, SUM(s.quantity) as current_stock
FROM inventory_items i
LEFT JOIN inventory_categories c ON i.category_id = c.id
LEFT JOIN inventory_stock s ON s.item_id = i.id AND s.status = 'available'
GROUP BY i.id
HAVING current_stock <= i.min_stock OR current_stock IS NULL
`).all();
}
module.exports = {
getAllCategories, createCategory, updateCategory, deleteCategory,
getAllItems, getItemById, createItem, updateItem, deleteItem,
getStockByItem, addStock, assignStockToCustomer, adjustStock,
getInventoryLogs, getLowStockItems
};
+46
View File
@@ -0,0 +1,46 @@
'use strict';
/**
* invoiceQrisService.js — pastikan kode unik + payload QRIS dinamis untuk sebuah invoice.
* QRIS dinamis = QRIS statis (settings.qris_static_payload) + nominal (amount + kode unik).
*/
const db = require('../config/database');
const qrisUtil = require('../utils/qrisUtil');
const { getSettings } = require('../config/settingsManager');
function ensureInvoiceQris(inv) {
const settings = getSettings() || {};
const invId = Number((inv && inv.id) || 0);
const base = Number((inv && inv.amount) || 0);
if (!invId || base <= 0) return { uniqueCode: 0, amount: base, payload: '' };
let code = Number(inv.qris_unique_code || 0);
let amountUnique = Number(inv.qris_amount_unique || 0);
if (!(amountUnique > 0 && code > 0 && (amountUnique - code) === base)) {
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
let chosen = 0, chosenAmt = 0;
for (let c = 1; c <= 999; c++) {
const a = base + c;
if (!exists.get('unpaid', a, invId)) { chosen = c; chosenAmt = a; break; }
}
if (chosen) {
code = chosen;
amountUnique = chosenAmt;
try {
db.prepare('UPDATE invoices SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL()) WHERE id=?')
.run(code, amountUnique, invId);
} catch (e) {}
}
}
const finalAmount = amountUnique > 0 ? amountUnique : base;
const raw = String(settings.qris_static_payload || '').trim();
let payload = '';
if (raw) {
try { payload = qrisUtil.convertStaticQrisToDynamic(raw, finalAmount); }
catch (e) { payload = raw; }
}
return { uniqueCode: code, amount: finalAmount, payload };
}
module.exports = { ensureInvoiceQris };
+77
View File
@@ -0,0 +1,77 @@
const axios = require('axios');
const { getSetting } = require('../config/settingsManager');
/**
* MAYAR.id payment gateway (Mode 1).
* Docs: https://docs.mayar.id — base https://api.mayar.id/hl/v2
* Auth: Authorization: Bearer <API_KEY>
*/
const MAYAR_BASE = 'https://api.mayar.id/hl/v2';
function apiKey() {
return String(getSetting('mayar_api_key', '') || '').trim();
}
function isConfigured() {
return !!apiKey();
}
function headers() {
return { Authorization: 'Bearer ' + apiKey(), 'Content-Type': 'application/json', Accept: 'application/json' };
}
function toWaMobile(phone) {
let d = String(phone || '').replace(/\D/g, '');
if (!d) return '';
if (d.startsWith('0')) d = '62' + d.slice(1);
return d;
}
/**
* Buat invoice/pembayaran di Mayar.
* @returns {{success:boolean, order_id:string, link:string, reference:string, payload:object}}
*/
async function createInvoice(opts) {
const o = opts || {};
if (!isConfigured()) throw new Error('Mayar belum dikonfigurasi (mayar_api_key kosong).');
const amount = Math.round(Number(o.amount) || 0);
if (amount <= 0) throw new Error('Nominal tagihan tidak valid untuk Mayar.');
const desc = String(o.description || 'Tagihan').slice(0, 200);
const body = {
name: String(o.name || 'Pelanggan').slice(0, 100),
email: String(o.email || ('no-reply+' + Date.now() + '@openisp.local')).slice(0, 120),
mobile: toWaMobile(o.mobile),
description: desc,
items: [{ quantity: 1, rate: amount, description: desc }],
extraData: o.extraData || {}
};
if (o.expiredAt) body.expiredAt = o.expiredAt;
const res = await axios.post(MAYAR_BASE + '/invoices/create', body, { headers: headers(), timeout: 25000 });
const d = (res.data && res.data.data) ? res.data.data : (res.data || {});
const link = d.link || d.paymentLink || d.payment_url || d.url || '';
const orderId = d.id || d.transactionId || d.paymentLinkId || '';
if (!link) throw new Error('Mayar tidak mengembalikan link pembayaran.');
return {
success: true,
order_id: String(orderId),
link: String(link),
reference: String(d.transactionId || orderId || ''),
payload: d,
raw: res.data
};
}
async function getInvoice(id) {
const res = await axios.get(MAYAR_BASE + '/invoices/' + encodeURIComponent(id), { headers: headers(), timeout: 20000 });
return res.data;
}
async function testConnection() {
if (!isConfigured()) return { ok: false, error: 'API key kosong' };
try {
const res = await axios.get(MAYAR_BASE + '/invoices?page=1&pageSize=1', { headers: headers(), timeout: 15000 });
return { ok: true, status: res.status };
} catch (e) {
return { ok: false, error: e && e.response ? ('HTTP ' + e.response.status) : (e.message || String(e)) };
}
}
module.exports = { isConfigured, createInvoice, getInvoice, testConnection, apiKey };
+283
View File
@@ -0,0 +1,283 @@
const { logger } = require('../config/logger');
const { getSetting } = require('../config/settingsManager');
const db = require('../config/database');
/**
* Service untuk Meta WhatsApp Cloud API (Official Meta Graph API)
*/
/**
* Format nomor telepon ke format E.164 tanpa tanda '+' (misal 628123456789)
*/
function normalizePhone(phone) {
let cleaned = String(phone || '').replace(/\D/g, '');
if (!cleaned) return '';
if (cleaned.startsWith('0')) {
cleaned = '62' + cleaned.slice(1);
}
return cleaned;
}
/**
* Verifikasi Webhook dari Meta (GET request saat pendaftaran Webhook di Meta Developer Console)
*/
function verifyWebhook(req, res) {
try {
const mode = req.query['hub.mode'];
const token = req.query['hub.verify_token'];
const challenge = req.query['hub.challenge'];
const configuredVerifyToken = getSetting('meta_verify_token', 'antigravity_meta_wa_secret');
if (mode === 'subscribe' && token === configuredVerifyToken) {
logger.info('[Meta WA Webhook] Webhook successfully verified by Meta!');
return res.status(200).send(challenge);
} else {
logger.warn('[Meta WA Webhook] Webhook verification failed! Invalid token.');
return res.sendStatus(403);
}
} catch (err) {
logger.error('[Meta WA Webhook] Verification error:', err.message);
return res.sendStatus(500);
}
}
/**
* Memproses Event Masuk dari Meta Webhook (Pesan Baru & Status Delivery)
*/
async function processWebhookEvent(req, res) {
try {
const body = req.body;
if (body.object !== 'whatsapp_business_account') {
return res.sendStatus(404);
}
const entries = body.entry || [];
for (const entry of entries) {
const changes = entry.changes || [];
for (const change of changes) {
const value = change.value;
if (!value) continue;
// 1. Tangani Pesan Masuk dari Pelanggan
if (value.messages && value.messages.length > 0) {
for (const msg of value.messages) {
const senderPhone = msg.from; // format: 628123456789
const msgId = msg.id;
let msgText = '';
if (msg.type === 'text' && msg.text) {
msgText = msg.text.body;
} else if (msg.type === 'button' && msg.button) {
msgText = msg.button.text;
} else if (msg.type === 'interactive' && msg.interactive) {
msgText = msg.interactive.button_reply?.title || msg.interactive.list_reply?.title || 'Interactive response';
} else {
msgText = `[Pesan ${msg.type}]`;
}
logger.info(`[Meta WA Webhook] Pesan masuk dari ${senderPhone}: "${msgText}"`);
// Cari nama pelanggan dari DB jika ada
let customerName = 'Pelanggan';
let customerId = null;
try {
const cust = db.prepare('SELECT id, name FROM customers WHERE phone LIKE ? OR phone LIKE ?').get(`%${senderPhone.slice(-8)}%`, `%${senderPhone}%`);
if (cust) {
customerName = cust.name;
customerId = cust.id;
}
} catch (e) {}
// Simpan ke tabel wa_chat_messages
try {
db.prepare(`
INSERT INTO wa_chat_messages
(direction, gateway, sender_phone, recipient_phone, customer_id, customer_name, message_text, status, meta_message_id)
VALUES ('inbound', 'meta', ?, ?, ?, ?, ?, 'read', ?)
`).run(senderPhone, getSetting('meta_business_phone', ''), customerId, customerName, msgText, msgId);
} catch (dbErr) {
logger.error('[Meta WA DB Error] Gagal simpan pesan masuk:', dbErr.message);
}
}
}
// 2. Tangani Update Status Pengiriman (Sent, Delivered, Read, Failed)
if (value.statuses && value.statuses.length > 0) {
for (const statusObj of value.statuses) {
const statusId = statusObj.id;
const newStatus = statusObj.status; // sent, delivered, read, failed
try {
db.prepare(`UPDATE wa_chat_messages SET status = ? WHERE meta_message_id = ?`).run(newStatus, statusId);
} catch (e) {}
}
}
}
}
return res.status(200).send('EVENT_RECEIVED');
} catch (err) {
logger.error('[Meta WA Webhook] Error processing webhook event:', err.message);
return res.status(500).send('ERROR');
}
}
/**
* Kirim Pesan Teks Bebas via Meta Cloud API (Graph API)
*/
async function sendMetaTextMessage(toPhone, messageText) {
const phone = normalizePhone(toPhone);
if (!phone) throw new Error('Nomor tujuan tidak valid.');
const phoneNumberId = getSetting('meta_phone_number_id', '');
const accessToken = getSetting('meta_access_token', '');
if (!phoneNumberId || !accessToken) {
throw new Error('Meta Cloud API belum dikonfigurasi. Harap isi Phone Number ID dan Access Token di Pengaturan WA.');
}
const url = `https://graph.facebook.com/v18.0/${phoneNumberId}/messages`;
const payload = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: phone,
type: 'text',
text: {
preview_url: false,
body: String(messageText || '')
}
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const resData = await response.json();
if (!response.ok) {
const errMessage = resData?.error?.message || resData?.error?.error_user_msg || JSON.stringify(resData);
logger.error(`[Meta WA API Error] ${errMessage}`);
throw new Error(`Meta API Error: ${errMessage}`);
}
const metaMsgId = resData?.messages?.[0]?.id || '';
// Simpan ke DB Chat Log
try {
let custName = 'Pelanggan';
let custId = null;
const cust = db.prepare('SELECT id, name FROM customers WHERE phone LIKE ? OR phone LIKE ?').get(`%${phone.slice(-8)}%`, `%${phone}%`);
if (cust) {
custName = cust.name;
custId = cust.id;
}
db.prepare(`
INSERT INTO wa_chat_messages
(direction, gateway, sender_phone, recipient_phone, customer_id, customer_name, message_text, status, meta_message_id)
VALUES ('outbound', 'meta', ?, ?, ?, ?, ?, 'sent', ?)
`).run(getSetting('meta_business_phone', ''), phone, custId, custName, messageText, metaMsgId);
} catch (e) {}
return { success: true, messageId: metaMsgId, raw: resData };
}
/**
* Kirim Pesan Template Resmi via Meta Cloud API
* @param {string} toPhone
* @param {string} templateName Nama template terdaftar di Meta
* @param {string} languageCode Kode bahasa, misal 'id'
* @param {Array} parameters Array parameter string misal ["Budi", "Rp 150.000", "10 Aug"]
*/
async function sendMetaTemplateMessage(toPhone, templateName, languageCode = 'id', parameters = []) {
const phone = normalizePhone(toPhone);
if (!phone) throw new Error('Nomor tujuan tidak valid.');
const phoneNumberId = getSetting('meta_phone_number_id', '');
const accessToken = getSetting('meta_access_token', '');
if (!phoneNumberId || !accessToken) {
throw new Error('Meta Cloud API belum dikonfigurasi. Harap isi Phone Number ID dan Access Token di Pengaturan WA.');
}
const url = `https://graph.facebook.com/v18.0/${phoneNumberId}/messages`;
const components = [];
if (Array.isArray(parameters) && parameters.length > 0) {
components.push({
type: 'body',
parameters: parameters.map(val => ({
type: 'text',
text: String(val || '')
}))
});
}
const payload = {
messaging_product: 'whatsapp',
to: phone,
type: 'template',
template: {
name: templateName,
language: {
code: languageCode
},
components: components
}
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const resData = await response.json();
if (!response.ok) {
const errMessage = resData?.error?.message || resData?.error?.error_user_msg || JSON.stringify(resData);
logger.error(`[Meta WA API Error] ${errMessage}`);
throw new Error(`Meta API Error: ${errMessage}`);
}
const metaMsgId = resData?.messages?.[0]?.id || '';
// Simpan ke DB Chat Log
try {
let custName = 'Pelanggan';
let custId = null;
const cust = db.prepare('SELECT id, name FROM customers WHERE phone LIKE ? OR phone LIKE ?').get(`%${phone.slice(-8)}%`, `%${phone}%`);
if (cust) {
custName = cust.name;
custId = cust.id;
}
const summaryText = `[Template Meta: ${templateName}] Parameter: ${parameters.join(', ')}`;
db.prepare(`
INSERT INTO wa_chat_messages
(direction, gateway, sender_phone, recipient_phone, customer_id, customer_name, message_text, status, meta_message_id)
VALUES ('outbound', 'meta', ?, ?, ?, ?, ?, 'sent', ?)
`).run(getSetting('meta_business_phone', ''), phone, custId, custName, summaryText, metaMsgId);
} catch (e) {}
return { success: true, messageId: metaMsgId, raw: resData };
}
module.exports = {
normalizePhone,
verifyWebhook,
processWebhookEvent,
sendMetaTextMessage,
sendMetaTemplateMessage
};
File diff suppressed because it is too large. Load diff
+422
View File
@@ -0,0 +1,422 @@
/**
* Service: Performance Monitoring System
* Memantau kesehatan sistem dan performa aplikasi
*/
const os = require('os');
const fs = require('fs');
const path = require('path');
const { logger } = require('../config/logger');
// Store metrics history
const metricsHistory = new Map();
const MAX_HISTORY_SIZE = 100;
// Track CPU time deltas for accurate percentage calculation
let previousCpuTimes = null;
function getCpuUsagePercent() {
const cpus = os.cpus();
if (!cpus || cpus.length === 0) return '0.00';
let user = 0, nice = 0, sys = 0, idle = 0, irq = 0;
for (const cpu of cpus) {
user += cpu.times.user;
nice += cpu.times.nice;
sys += cpu.times.sys;
idle += cpu.times.idle;
irq += cpu.times.irq;
}
const total = user + nice + sys + idle + irq;
if (!previousCpuTimes) {
previousCpuTimes = { user, nice, sys, idle, irq, total };
return '5.00'; // Default safe initial value
}
const totalDelta = total - previousCpuTimes.total;
const idleDelta = idle - previousCpuTimes.idle;
previousCpuTimes = { user, nice, sys, idle, irq, total };
if (totalDelta <= 0) return '0.00';
const usage = Math.min(100, Math.max(0, ((totalDelta - idleDelta) / totalDelta) * 100));
return usage.toFixed(2);
}
/**
* Get system metrics
*/
function getSystemMetrics() {
const cpus = os.cpus();
const totalMemory = os.totalmem();
const freeMemory = os.freemem();
const usedMemory = totalMemory - freeMemory;
// Calculate real CPU usage percentage
const cpuPercent = getCpuUsagePercent();
// Calculate memory usage percentage
const memoryPercent = (usedMemory / totalMemory) * 100;
// Get uptime
const uptime = process.uptime();
const uptimeHours = Math.floor(uptime / 3600);
const uptimeMinutes = Math.floor((uptime % 3600) / 60);
const uptimeSeconds = Math.floor(uptime % 60);
// Get load average (Fallback for Windows where loadavg is [0, 0, 0])
const loadAverage = os.loadavg();
let load1 = loadAverage[0];
let load5 = loadAverage[1];
let load15 = loadAverage[2];
if (os.platform() === 'win32' || load1 === 0) {
const estLoad = ((parseFloat(cpuPercent) / 100) * cpus.length).toFixed(2);
load1 = parseFloat(estLoad);
load5 = parseFloat(estLoad);
load15 = parseFloat(estLoad);
}
return {
timestamp: new Date().toISOString(),
cpu: {
usage: cpuPercent,
cores: cpus.length,
model: cpus[0] ? cpus[0].model : 'CPU',
speed: cpus[0] ? cpus[0].speed : 0
},
memory: {
total: formatBytes(totalMemory),
free: formatBytes(freeMemory),
used: formatBytes(usedMemory),
percentage: memoryPercent.toFixed(2)
},
uptime: {
seconds: uptime,
formatted: `${uptimeHours}h ${uptimeMinutes}m ${uptimeSeconds}s`
},
loadAverage: {
'1min': load1.toFixed(2),
'5min': load5.toFixed(2),
'15min': load15.toFixed(2)
},
platform: os.platform(),
arch: os.arch(),
nodeVersion: process.version
};
}
/**
* Get application metrics
*/
function getAppMetrics() {
const memoryUsage = process.memoryUsage();
return {
pid: process.pid,
memory: {
rss: formatBytes(memoryUsage.rss),
heapTotal: formatBytes(memoryUsage.heapTotal),
heapUsed: formatBytes(memoryUsage.heapUsed),
external: formatBytes(memoryUsage.external),
arrayBuffers: formatBytes(memoryUsage.arrayBuffers)
},
uptime: process.uptime(),
timestamp: new Date().toISOString()
};
}
/**
* Get disk usage (cross-platform with statfsSync)
*/
function getDiskUsage() {
try {
const targetPath = os.platform() === 'win32' ? 'C:\\' : '/';
if (typeof fs.statfsSync === 'function') {
const stat = fs.statfsSync(targetPath);
const total = stat.bsize * stat.blocks;
const free = stat.bsize * stat.bfree;
const used = Math.max(0, total - free);
const percentage = total > 0 ? ((used / total) * 100).toFixed(2) + '%' : 'N/A';
return {
total: formatBytes(total),
free: formatBytes(free),
used: formatBytes(used),
percentage,
timestamp: new Date().toISOString()
};
}
// Unix fallback
if (os.platform() !== 'win32') {
const { execSync } = require('child_process');
const output = execSync(`df -h ${targetPath}`).toString();
const lines = output.split('\n');
if (lines.length > 1) {
const parts = lines[1].split(/\s+/);
return {
total: parts[1],
used: parts[2],
free: parts[3],
percentage: parts[4],
timestamp: new Date().toISOString()
};
}
}
return {
total: 'N/A',
free: 'N/A',
used: 'N/A',
percentage: 'N/A',
timestamp: new Date().toISOString()
};
} catch (e) {
logger.error(`[Monitoring] Failed to get disk usage: ${e.message}`);
return {
total: 'N/A',
free: 'N/A',
used: 'N/A',
percentage: 'N/A',
timestamp: new Date().toISOString()
};
}
}
/**
* Get database metrics
*/
function getDatabaseMetrics() {
try {
const db = require('../config/database');
const dbPath = path.join(__dirname, '../database/billing.db');
let statsSize = 0;
try {
const stats = fs.statSync(dbPath);
statsSize = stats.size;
} catch {}
// Get table counts
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
const tableCounts = {};
for (const table of tables) {
try {
const count = db.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get();
tableCounts[table.name] = count.count;
} catch (e) {
tableCounts[table.name] = 'N/A';
}
}
return {
size: formatBytes(statsSize),
path: dbPath,
tables: tableCounts,
totalTables: tables.length,
timestamp: new Date().toISOString()
};
} catch (e) {
logger.error(`[Monitoring] Failed to get database metrics: ${e.message}`);
return {
size: 'N/A',
path: 'N/A',
tables: {},
totalTables: 0,
timestamp: new Date().toISOString()
};
}
}
/**
* Get all metrics
*/
function getAllMetrics() {
const system = getSystemMetrics();
const app = getAppMetrics();
const disk = getDiskUsage();
const database = getDatabaseMetrics();
const allMetrics = {
system,
app,
disk,
database,
timestamp: new Date().toISOString()
};
// Store in history
storeMetricsHistory(allMetrics);
return allMetrics;
}
/**
* Store metrics in history
*/
function storeMetricsHistory(metrics) {
const key = metrics.timestamp;
metricsHistory.set(key, metrics);
// Keep only the last MAX_HISTORY_SIZE entries
if (metricsHistory.size > MAX_HISTORY_SIZE) {
const oldestKey = metricsHistory.keys().next().value;
metricsHistory.delete(oldestKey);
}
}
/**
* Get metrics history
*/
function getMetricsHistory(limit = 10) {
if (metricsHistory.size < 2) {
getAllMetrics(); // Guarantee at least 1 sample if history is thin
}
const history = Array.from(metricsHistory.values())
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
.slice(0, limit);
return history;
}
/**
* Get health status
*/
function getHealthStatus() {
const metrics = getAllMetrics();
const issues = [];
const warnings = [];
// Check CPU usage
const cpuUsage = parseFloat(metrics.system.cpu.usage);
if (cpuUsage > 85) {
issues.push(`Penggunaan CPU tinggi: ${cpuUsage}%`);
} else if (cpuUsage > 65) {
warnings.push(`Penggunaan CPU agak tinggi: ${cpuUsage}%`);
}
// Check memory usage
const memoryUsage = parseFloat(metrics.system.memory.percentage);
if (memoryUsage > 85) {
issues.push(`Penggunaan RAM tinggi: ${memoryUsage}%`);
} else if (memoryUsage > 70) {
warnings.push(`Penggunaan RAM agak tinggi: ${memoryUsage}%`);
}
// Check load average
const load1 = parseFloat(metrics.system.loadAverage['1min']);
const cores = metrics.system.cpu.cores;
if (load1 > cores * 2) {
issues.push(`Beban sistem (Load Average) tinggi: ${load1} (${cores} cores)`);
} else if (load1 > cores) {
warnings.push(`Beban sistem (Load Average) agak tinggi: ${load1} (${cores} cores)`);
}
// Check disk usage
const diskPercentage = metrics.disk.percentage;
if (diskPercentage !== 'N/A') {
const diskPercent = parseFloat(diskPercentage.replace('%', ''));
if (diskPercent > 90) {
issues.push(`Kapasitas Penyimpanan (Disk) Hampir Penuh: ${diskPercentage}`);
} else if (diskPercent > 75) {
warnings.push(`Kapasitas Penyimpanan (Disk) Terpakai: ${diskPercentage}`);
}
}
// Determine overall health status
let status = 'healthy';
if (issues.length > 0) {
status = 'critical';
} else if (warnings.length > 0) {
status = 'warning';
}
return {
status,
issues,
warnings,
metrics,
timestamp: new Date().toISOString()
};
}
/**
* Format bytes to human readable format
*/
function formatBytes(bytes) {
if (typeof bytes !== 'number' || isNaN(bytes) || bytes <= 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
/**
* Get performance summary
*/
function getPerformanceSummary() {
const history = getMetricsHistory(10);
if (history.length === 0) {
return {
timestamp: new Date().toISOString(),
summary: 'No historical data available'
};
}
// Calculate averages
const avgCpu = history.reduce((sum, m) => sum + parseFloat(m.system.cpu.usage || 0), 0) / history.length;
const avgMemory = history.reduce((sum, m) => sum + parseFloat(m.system.memory.percentage || 0), 0) / history.length;
const avgLoad1 = history.reduce((sum, m) => sum + parseFloat(m.system.loadAverage['1min'] || 0), 0) / history.length;
// Find max values
const maxCpu = Math.max(...history.map(m => parseFloat(m.system.cpu.usage || 0)));
const maxMemory = Math.max(...history.map(m => parseFloat(m.system.memory.percentage || 0)));
const maxLoad1 = Math.max(...history.map(m => parseFloat(m.system.loadAverage['1min'] || 0)));
return {
timestamp: new Date().toISOString(),
period: {
start: history[history.length - 1].timestamp,
end: history[0].timestamp,
samples: history.length
},
averages: {
cpu: avgCpu.toFixed(2),
memory: avgMemory.toFixed(2),
load1: avgLoad1.toFixed(2)
},
maximums: {
cpu: maxCpu.toFixed(2),
memory: maxMemory.toFixed(2),
load1: maxLoad1.toFixed(2)
}
};
}
/**
* Clear metrics history
*/
function clearMetricsHistory() {
metricsHistory.clear();
logger.info('[Monitoring] Metrics history cleared');
}
// Populate initial sample on module load
try {
getAllMetrics();
} catch {}
module.exports = {
getSystemMetrics,
getAppMetrics,
getDiskUsage,
getDatabaseMetrics,
getAllMetrics,
getMetricsHistory,
getHealthStatus,
getPerformanceSummary,
clearMetricsHistory
};
+124
View File
@@ -0,0 +1,124 @@
const db = require('../config/database');
/**
* ODP SERVICE
* Mengelola data Optical Distribution Point (ODP) + skema port.
* - port_capacity = jumlah maksimal port pada ODP.
* - customers.pon_port = nomor port ODP yang dipakai pelanggan.
*/
function getAllOdps() {
return db.prepare(`
SELECT o.*, olt.name as olt_name
FROM odps o
LEFT JOIN olts olt ON o.olt_id = olt.id
ORDER BY o.name ASC
`).all();
}
function getOdpById(id) {
return db.prepare('SELECT * FROM odps WHERE id = ?').get(id);
}
function parseCapacity(v) {
const n = parseInt(v, 10);
return (Number.isFinite(n) && n > 0) ? n : 16;
}
function createOdp(data) {
const capacity = parseCapacity(data.port_capacity);
const stmt = db.prepare(`
INSERT INTO odps (name, olt_id, pon_port, port_capacity, lat, lng, description)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
return stmt.run(
data.name,
data.olt_id ? parseInt(data.olt_id) : null,
data.pon_port || '',
capacity,
data.lat || '',
data.lng || '',
data.description || ''
);
}
function updateOdp(id, data) {
const capacity = parseCapacity(data.port_capacity);
const usage = getOdpPortUsage(id);
if (usage && capacity < usage.usedCount) {
throw new Error('Kapasitas port (' + capacity + ') lebih kecil dari jumlah port yang sudah terpakai (' + usage.usedCount + ').');
}
const stmt = db.prepare(`
UPDATE odps
SET name = ?, olt_id = ?, pon_port = ?, port_capacity = ?, lat = ?, lng = ?, description = ?
WHERE id = ?
`);
return stmt.run(
data.name,
data.olt_id ? parseInt(data.olt_id) : null,
data.pon_port || '',
capacity,
data.lat || '',
data.lng || '',
data.description || '',
id
);
}
function deleteOdp(id) {
return db.prepare('DELETE FROM odps WHERE id = ?').run(id);
}
function getOdpPortUsage(odpId) {
const odp = getOdpById(odpId);
if (!odp) return null;
const usedRaw = db.prepare("SELECT pon_port, name, pppoe_username FROM customers WHERE odp_id = ? AND pon_port IS NOT NULL AND TRIM(pon_port) != ''").all(odpId);
const usedBy = {};
for (const r of usedRaw) {
const p = String(r.pon_port).trim();
if (p && usedBy[p] === undefined) usedBy[p] = { name: r.name || '', pppoe: String(r.pppoe_username || '').toLowerCase() };
}
const usedPorts = Object.keys(usedBy).sort((a, b) => a.localeCompare(b, 'id-ID', { numeric: true }));
const capacity = parseCapacity(odp.port_capacity);
const usedCount = usedPorts.length;
const remaining = Math.max(0, capacity - usedCount);
return { odpId: Number(odpId), capacity, usedCount, remaining, usedPorts, usedBy };
}
/**
* Validasi penempatan port pelanggan pada sebuah ODP.
* - Port tidak boleh melebihi kapasitas (maksimal port) ODP.
* - Port tidak boleh dipakai pelanggan lain pada ODP yang sama.
* Nilai non-numerik (data lama) dilewatkan agar tetap kompatibel.
*/
function validateOdpPort(odpId, ponPort, excludeCustomerId) {
if (odpId === undefined || odpId === null || String(odpId).trim() === '') return;
const portStr = String(ponPort === undefined || ponPort === null ? '' : ponPort).trim();
if (!portStr) return;
if (!/^\d+$/.test(portStr)) return;
const odp = getOdpById(Number(odpId));
if (!odp) throw new Error('ODP tidak ditemukan.');
const capacity = parseCapacity(odp.port_capacity);
const portNo = parseInt(portStr, 10);
if (portNo < 1 || portNo > capacity) {
throw new Error('Port ODP ' + portNo + ' melebihi kapasitas ODP "' + odp.name + '" (maksimal ' + capacity + ' port). Ubah kapasitas ODP bila perlu.');
}
const ex = (excludeCustomerId === undefined || excludeCustomerId === null || excludeCustomerId === '') ? null : Number(excludeCustomerId);
const dup = ex
? db.prepare("SELECT id, name FROM customers WHERE odp_id = ? AND TRIM(COALESCE(pon_port,'')) = ? AND id != ? LIMIT 1").get(Number(odpId), portStr, ex)
: db.prepare("SELECT id, name FROM customers WHERE odp_id = ? AND TRIM(COALESCE(pon_port,'')) = ? LIMIT 1").get(Number(odpId), portStr);
if (dup) throw new Error('Port ODP ' + portNo + ' sudah dipakai oleh pelanggan "' + dup.name + '".');
}
module.exports = {
getAllOdps,
getOdpById,
createOdp,
updateOdp,
deleteOdp,
getOdpPortUsage,
validateOdpPort
};
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+226
View File
@@ -0,0 +1,226 @@
/**
* Service: Sinkronisasi ONU (OLT) dengan Data Pelanggan.
*
* Dua tugas dalam satu siklus:
* 1. Auto-isi/perbarui `mac_address` pelanggan dari caller-id sesi PPPoE aktif
* HANYA bila caller-id terverifikasi sebagai serial ONU yang benar-benar ada
* di OLT (agar MAC router/Cpe bridge tidak tersimpan salah). Nilai lama akan
* diperbarui sehingga penggantian ONU ikut terdeteksi otomatis.
* 2. Ganti nama ONU di OLT menjadi `pppoe_username` / nama pelanggan berdasarkan
* kecocokan: mac_address pelanggan, enrichment stats, atau caller-id PPPoE live.
*
* Satu kali pengambilan daftar ONU per OLT (SNMP walk) dipakai untuk kedua tugas.
*/
const { logger } = require('../config/logger');
const db = require('../config/database');
const oltService = require('./oltService');
const mikrotikService = require('./mikrotikService');
function normalizeKey(s) {
return String(s || '').replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
}
/**
* Normalisasi MAC menjadi format AA:BB:CC:DD:EE:FF. Kembalikan null bila bukan 12 hex.
*/
function formatMac(raw) {
const hex = String(raw || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (hex.length !== 12) return null;
return hex.match(/.{2}/g).join(':');
}
function loadCustomerContext() {
const custs = db.prepare('SELECT id, name, pppoe_username, mac_address FROM customers').all() || [];
const byId = new Map();
const byMac = new Map();
const byUser = new Map();
for (const c of custs) {
byId.set(c.id, c);
if (c.mac_address) byMac.set(normalizeKey(c.mac_address), c);
if (c.pppoe_username) byUser.set(String(c.pppoe_username).trim().toLowerCase(), c);
}
return { custs, byId, byMac, byUser };
}
/**
* Bangun peta caller-id (MAC) -> username PPPoE dari sesi aktif.
*/
function buildMacToUser(sessions) {
const map = new Map();
for (const [username, s] of sessions.entries()) {
const k = normalizeKey(s.callerId);
if (k && !map.has(k)) map.set(k, username);
}
return map;
}
/**
* Isi/perbarui mac_address pelanggan dari caller-id PPPoE, hanya untuk MAC ONU
* yang terverifikasi ada di daftar serial OLT. Tampilkan/lewati konflik.
*/
function refreshCustomerMacs(sessions, byUser, byMac, onuSerialSet, dryRun) {
const stmt = db.prepare('UPDATE customers SET mac_address = ? WHERE id = ?');
const summary = { total: sessions.size, filled: 0, refreshed: 0, skippedNonOnu: 0, noCustomer: 0, conflict: 0, errors: [] };
for (const [username, s] of sessions.entries()) {
const cust = byUser.get(username);
if (!cust) {
summary.noCustomer++;
continue;
}
const mac = formatMac(s.callerId);
if (!mac) {
summary.skippedNonOnu++;
continue;
}
const norm = normalizeKey(mac);
if (!onuSerialSet.has(norm)) {
summary.skippedNonOnu++;
continue;
}
const owner = byMac.get(norm);
if (owner && owner.id !== cust.id) {
summary.conflict++;
logger.warn(`[SyncMAC] MAC ONU ${mac} sudah terpakai pelanggan lain (${owner.name}); lewati ${cust.name}`);
continue;
}
const current = String(cust.mac_address || '').trim();
if (current && normalizeKey(current) === norm) continue;
try {
if (!dryRun) stmt.run(mac, cust.id);
if (current) summary.refreshed++;
else summary.filled++;
if (current) byMac.delete(normalizeKey(current));
byMac.set(norm, cust);
logger.info(`[SyncMAC] ${dryRun ? '[DRY-RUN] ' : ''}${current ? 'update' : 'isi'} mac_address ${cust.name} => ${mac}`);
} catch (err) {
summary.errors.push({ customer: cust.name, error: err.message });
logger.error(`[SyncMAC] Gagal ${current ? 'update' : 'isi'} mac_address ${cust.name}: ${err.message}`);
}
}
return summary;
}
/**
* Tentukan target nama untuk satu ONU.
* @returns {{customer: object|null, target: string|null, via: string|null}}
*/
function resolveOnuTarget(onu, byId, byMac, byUser, macToUser) {
const snNorm = normalizeKey(onu.sn);
let customer = onu.customer_id ? byId.get(onu.customer_id) : null;
let via = 'mac_map';
if (!customer) {
if (snNorm && byMac.has(snNorm)) {
customer = byMac.get(snNorm);
via = 'mac_map';
} else if (snNorm && macToUser.has(snNorm)) {
const uname = macToUser.get(snNorm);
customer = byUser.get(uname) || null;
via = customer ? 'pppoe_live' : null;
}
}
if (!customer) return { customer: null, target: null, via: null };
const target = (customer.pppoe_username && String(customer.pppoe_username).trim())
? String(customer.pppoe_username).trim()
: String(customer.name || '').trim();
return { customer, target, via };
}
async function syncOnuNamesFromStats(olt, onus, ctx, macToUser, dryRun) {
const summary = { oltId: olt.id, oltName: olt.name, total: onus.length, renamed: 0, failed: 0, same: 0, unmatch: 0, errors: [] };
for (const onu of onus) {
const { customer, target, via } = resolveOnuTarget(onu, ctx.byId, ctx.byMac, ctx.byUser, macToUser);
if (!customer || !target) {
summary.unmatch++;
continue;
}
const writable = target.slice(0, 30);
if (String(onu.name || '').trim() === writable) {
summary.same++;
continue;
}
try {
if (!dryRun) await oltService.renameOnu(olt.id, onu.index, target);
summary.renamed++;
logger.info(`[SyncONU] ${dryRun ? '[DRY-RUN] akan rename' : 'rename'} ${onu.id} (${onu.sn}) => ${target} (${via})`);
} catch (err) {
summary.failed++;
summary.errors.push({ onu: onu.id, sn: onu.sn, target, error: err.message });
logger.error(`[SyncONU] Gagal rename ${onu.id} => ${target}: ${err.message}`);
}
}
return summary;
}
/**
* Orkestrator sinkronisasi: mengambil daftar ONU sekali per OLT, lalu
* (1) auto-isi/perbarui mac_address dan (2) rename nama ONU.
* @param {{dryRun?: boolean, oltId?: number|string, skipFill?: boolean, skipNames?: boolean}} opts
*/
async function runSyncOnu(opts = {}) {
const dryRun = !!opts.dryRun;
const onlyOltId = opts.oltId != null ? String(opts.oltId) : null;
let olts = (oltService.getAllOlts() || []).filter((o) => o.is_active === 1 || o.is_active === true);
if (onlyOltId) olts = olts.filter((o) => String(o.id) === onlyOltId);
// 1) Ambil daftar ONU (satu walk per OLT) + kumpulkan serial ONU
const onusByOlt = [];
const onuSerialSet = new Map();
for (const olt of olts) {
try {
const stats = await oltService.getOltStats(olt.id, true);
const onus = (stats && stats.onus) || [];
onusByOlt.push({ olt, onus });
for (const onu of onus) {
const k = normalizeKey(onu.sn);
if (k && !onuSerialSet.has(k)) onuSerialSet.set(k, { oltId: olt.id, oltName: olt.name, onuId: onu.id });
}
} catch (err) {
logger.error(`[SyncONU] Gagal ambil daftar ONU ${olt.name}: ${err.message}`);
onusByOlt.push({ olt, onus: [], error: err.message });
}
}
// 2) Sesi PPPoE aktif
let sessions = new Map();
try {
sessions = await mikrotikService.getActivePppoeSessionsMap();
} catch (e) {
logger.error(`[SyncMAC] Gagal ambil sesi PPPoE: ${e.message}`);
}
// 3) Auto-isi/perbarui mac_address (hanya MAC ONU terverifikasi)
let mac;
if (opts.skipFill) {
mac = { skipped: true, total: sessions.size };
} else {
const ctx0 = loadCustomerContext();
mac = refreshCustomerMacs(sessions, ctx0.byUser, ctx0.byMac, onuSerialSet, dryRun);
}
// 4) Sinkronkan nama ONU (pakai data pelanggan terbaru)
let summaries = [];
if (!opts.skipNames) {
const macToUser = buildMacToUser(sessions);
const ctx1 = loadCustomerContext();
for (const { olt, onus } of onusByOlt) {
summaries.push(await syncOnuNamesFromStats(olt, onus, ctx1, macToUser, dryRun));
}
}
return { mac, olts: summaries };
}
/**
* Sinkronkan nama ONU untuk satu OLT (kompatibilitas).
*/
async function syncOnuCustomerNames(oltId, opts = {}) {
const res = await runSyncOnu({ dryRun: !!opts.dryRun, oltId, skipFill: true });
const s = (res.olts && res.olts[0]) || null;
if (!s) throw new Error('OLT tidak ditemukan');
return s;
}
module.exports = { runSyncOnu, syncOnuCustomerNames, formatMac };
+165
View File
@@ -0,0 +1,165 @@
'use strict';
/**
* packageProfileService.js — Sinkronisasi paket → (IP pool + profil PPP) MikroTik, RADIUS-aware.
*
* Kapan objek MikroTik (pool/profil) diperlukan?
* - Mode MikroTik (radius_enabled != '1') → YA (profil: rate-limit + pool IP)
* - Mode RADIUS + radius_use_profile = '1' (pakai profil/GROUP) → YA (RADIUS kirim Mikrotik-Group = nama paket; profil+pool dipakai MikroTik)
* - Mode RADIUS + radius_use_profile = '0' (rate-limit atribut) → TIDAK (rate-limit & IP dari atribut RADIUS)
*
* Comment objek = "manage by cloud" (penanda objek dikelola aplikasi).
*/
const { getSetting } = require('../config/settingsManager');
const mikrotikService = require('./mikrotikService');
const { logger } = require('../config/logger');
const MANAGE_COMMENT = 'manage by cloud';
function kbpsToStr(kbps) {
kbps = Math.round(Number(kbps) || 0);
if (kbps <= 0) return null;
if (kbps >= 1000 && kbps % 1000 === 0) return (kbps / 1000) + 'M';
return kbps + 'k';
}
// Format rate-limit native MikroTik: rx/tx rxBurst/txBurst rxThreshold/txThreshold burstTime(rx/tx)
// Threshold = rate dasar; burst-time = 8/8. Contoh: 10M/10M 13M/13M 10M/10M 8/8
function rateLimitStr(pkg) {
// rx/tx = dari sudut pandang router: rx = UPLOAD klien, tx = DOWNLOAD klien
const rx = kbpsToStr(pkg.speed_up);
const tx = kbpsToStr(pkg.speed_down);
if (!rx || !tx) return null;
const rxB = kbpsToStr(pkg.speed_up_upto);
const txB = kbpsToStr(pkg.speed_down_upto);
if (rxB && txB && (Number(pkg.speed_up_upto) > Number(pkg.speed_up) || Number(pkg.speed_down_upto) > Number(pkg.speed_down))) {
return rx + '/' + tx + ' ' + rxB + '/' + txB + ' ' + rx + '/' + tx + ' 8/8';
}
return rx + '/' + tx;
}
function currentProfileMode() {
const radiusEnabled = String(getSetting('radius_enabled', '0')) === '1';
const useProfile = String(getSetting('radius_use_profile', '0')) === '1';
if (!radiusEnabled) return 'mikrotik'; // mode MikroTik
return useProfile ? 'radius-profile' : 'radius-attributes';
}
// Bangun object field profil PPP dari paket (hanya field yang diisi).
function buildProfileFields(pkg) {
const rl = rateLimitStr(pkg);
const data = { name: pkg.name, comment: MANAGE_COMMENT };
if (rl) data['rate-limit'] = rl;
data['only-one'] = (pkg.only_one === 0 || pkg.only_one === '0') ? 'no' : 'yes';
const poolName = String(pkg.pool_name || '').trim();
if (poolName) data['remote-address'] = poolName;
if (String(pkg.local_address || '').trim()) data['local-address'] = String(pkg.local_address).trim().split('/')[0].trim();
if (String(pkg.parent_queue || '').trim()) data['parent-queue'] = String(pkg.parent_queue).trim();
if (String(pkg.queue_type || '').trim()) data['queue-type'] = String(pkg.queue_type).trim();
return { data, rl };
}
async function ensureIpPool(pkg, routerId) {
const poolName = String(pkg.pool_name || '').trim();
const poolRanges = String(pkg.pool_ranges || '').trim();
if (!poolName || !poolRanges) return null;
try {
const pools = await mikrotikService.getIpPools(routerId).catch(() => []);
const ex = (pools || []).find((p) => String(p.name || '').trim().toLowerCase() === poolName.toLowerCase());
if (ex) {
await mikrotikService.updateIpPool(ex.id || ex['.id'], { ranges: poolRanges, comment: MANAGE_COMMENT }, routerId);
return 'IP pool "' + poolName + '" diperbarui';
}
await mikrotikService.addIpPool({ name: poolName, ranges: poolRanges, comment: MANAGE_COMMENT }, routerId);
return 'IP pool "' + poolName + '" dibuat';
} catch (e) {
logger.warn('[PackageProfile] Gagal sinkron IP pool "' + poolName + '": ' + e.message);
return 'IP pool GAGAL: ' + e.message;
}
}
/**
* Sinkronkan paket → IP pool + profil PPP MikroTik (RADIUS-aware).
* @returns {Promise<{needed:boolean, action?:string, mode:string, message:string}>}
*/
async function ensurePppProfileForPackage(pkg) {
const mode = currentProfileMode();
if (mode === 'radius-attributes') {
return { needed: false, mode, message: 'Mode RADIUS (rate-limit & IP dari atribut) — pool/profil MikroTik tidak dibuat.' };
}
if (!pkg || !pkg.name) return { needed: true, mode, message: 'Nama paket kosong.' };
const routerId = pkg.router_id || null;
const parts = [];
const poolMsg = await ensureIpPool(pkg, routerId);
if (poolMsg) parts.push(poolMsg);
const { data, rl } = buildProfileFields(pkg);
if (!rl) parts.push('speed 0 → rate-limit tidak dibuat');
try {
const profiles = await mikrotikService.getPppoeProfiles(routerId);
const ex = (profiles || []).find((p) => String(p.name || '').trim().toLowerCase() === String(pkg.name).trim().toLowerCase());
if (ex) {
await mikrotikService.updatePppoeProfile(ex.id || ex['.id'], data, routerId);
parts.push('profil PPP diperbarui');
} else {
await mikrotikService.addPppoeProfile(data, routerId);
parts.push('profil PPP dibuat');
}
} catch (e) {
logger.warn('[PackageProfile] Gagal sinkron profil "' + pkg.name + '": ' + e.message);
parts.push('profil GAGAL: ' + e.message);
}
// Profil isolir paket (dibuat bila belum ada)
const isolirP = String(pkg.isolir_profile || '').trim();
if (isolirP) {
try {
const profs2 = await mikrotikService.getPppoeProfiles(routerId);
const ex2 = (profs2 || []).find((p) => String(p.name || '').trim().toLowerCase() === isolirP.toLowerCase());
if (!ex2) {
await mikrotikService.addPppoeProfile({ name: isolirP, 'rate-limit': '512k/512k', comment: MANAGE_COMMENT }, routerId);
parts.push('profil isolir "' + isolirP + '" dibuat (512k/512k)');
}
} catch (e) { parts.push('profil isolir GAGAL: ' + e.message); }
}
const prefix = (mode === 'radius-profile') ? '[RADIUS profil] ' : '';
const rlTxt = rl ? (' · rate-limit ' + rl) : '';
return { needed: true, mode, message: prefix + parts.join('; ') + rlTxt };
}
async function removePppProfileForPackage(pkg, opts) {
opts = opts || {};
const mode = currentProfileMode();
const out = [];
if (!pkg) return { mode, message: '' };
const routerId = pkg.router_id || null;
const name = String(pkg.name || '').trim();
if (mode !== 'radius-attributes' && name) {
try {
const profiles = await mikrotikService.getPppoeProfiles(routerId);
const ex = (profiles || []).find((p) => String(p.name || '').trim().toLowerCase() === name.toLowerCase());
if (ex && String(ex.comment || '').trim() === MANAGE_COMMENT) {
await mikrotikService.deletePppoeProfile(ex.id || ex['.id'], routerId);
out.push('profil PPP "' + name + '" dihapus');
} else if (ex) {
out.push('profil PPP "' + name + '" dibiarkan (bukan dikelola aplikasi)');
}
} catch (e) { out.push('profil gagal dihapus: ' + e.message); }
}
const poolName = String(pkg.pool_name || '').trim();
if (opts.removePool && poolName && mode !== 'radius-attributes') {
try {
const pools = await mikrotikService.getIpPools(routerId).catch(() => []);
const pex = (pools || []).find((p) => String(p.name || '').trim().toLowerCase() === poolName.toLowerCase());
if (pex && String(pex.comment || '').trim() === MANAGE_COMMENT) {
await mikrotikService.deleteIpPool(pex.id || pex['.id'], routerId);
out.push('IP pool "' + poolName + '" dihapus');
}
} catch (e) { out.push('pool gagal dihapus: ' + e.message); }
}
return { mode, message: out.join('; ') };
}
module.exports = { ensurePppProfileForPackage, removePppProfileForPackage, currentProfileMode, rateLimitStr, buildProfileFields, MANAGE_COMMENT };
+64
View File
@@ -0,0 +1,64 @@
/**
* Payment Service (REDESIGN) — hanya MAYAR (Mode 1).
* Gateway lama (Tripay/Midtrans/Xendit/Duitku) DIHAPUS.
* Mode 2 = QRIS statis + verifikasi manual (MacroDroid) — lihat customerPortal.js / voucherPaymentService.js.
*/
const mayar = require('./mayarService');
function isMayarConfigured() {
return mayar.isConfigured();
}
async function createMayarTransaction(invoice, customer, method, appUrl, opts = {}) {
const o = opts || {};
const inv = invoice || {};
const desc = o.itemName || inv.item_name || ('Tagihan ' + (inv.period_month ? inv.period_month + '/' + inv.period_year : ''));
return mayar.createInvoice({
name: (customer && customer.name) || 'Pelanggan',
email: (customer && customer.email) || '',
mobile: (customer && customer.phone) || '',
description: desc,
amount: Number(inv.amount || 0),
extraData: o.extraData || { invoice_id: String(inv.id || '') }
});
}
// ── Stub aman untuk fungsi gateway lama (agar require tidak error; pemanggilan = bug) ──
async function getTripayChannels() { return []; }
function verifyTripayWebhook() { return false; }
function verifyMidtransWebhook() { return false; }
function verifyDuitkuWebhook() { return false; }
function _removed(name) { throw new Error('Gateway ' + name + ' sudah dihapus. Gunakan Mayar (Mode 1) atau QRIS statis (Mode 2).'); }
function createTripayTransaction() { return _removed('Tripay'); }
function createMidtransTransaction() { return _removed('Midtrans'); }
function createXenditTransaction() { return _removed('Xendit'); }
function createDuitkuTransaction() { return _removed('Duitku'); }
// ── Mode-aware gateway resolver (dipakai app-customer.js & routes/customerAPI.js) ──
// Mode 1 'gateway' => hanya Mayar (butuh mayar_api_key). Mode 2 'selfservice' => QRIS statis.
function resolveConfiguredGatewayForAmount(settings, amount) {
const payMode = String((settings && settings.payment_mode) || 'selfservice').toLowerCase();
if (payMode === 'gateway') {
return String((settings && settings.mayar_api_key) || '').trim() ? 'mayar' : null;
}
if (settings && settings.qris_static_enabled && settings.qris_static_payload) return 'qris_static';
return null;
}
function resolveConfiguredGateway(settings) {
return resolveConfiguredGatewayForAmount(settings, 0);
}
module.exports = {
resolveConfiguredGateway,
resolveConfiguredGatewayForAmount,
isMayarConfigured,
createMayarTransaction,
getTripayChannels,
verifyTripayWebhook,
verifyMidtransWebhook,
verifyDuitkuWebhook,
createTripayTransaction,
createMidtransTransaction,
createXenditTransaction,
createDuitkuTransaction
};
+449
View File
@@ -0,0 +1,449 @@
/**
* Service: Payroll / Gaji Karyawan
* Mengelola pengaturan gaji, generate slip gaji, dan kalkulasi otomatis
*/
const db = require('../config/database');
const { logger } = require('../config/logger');
const attendanceSvc = require('./attendanceService');
const EMPLOYEE_TYPES = ['technician', 'cashier', 'collector'];
// ─── PAYROLL SETTINGS ────────────────────────────────────────────────────────
function getPayrollSetting(employeeType, employeeId) {
return db.prepare(
'SELECT * FROM payroll_settings WHERE employee_type = ? AND employee_id = ?'
).get(employeeType, employeeId) || null;
}
function getAllPayrollSettings() {
return db.prepare('SELECT * FROM payroll_settings ORDER BY employee_type, employee_id').all();
}
function upsertPayrollSetting(data) {
const existing = getPayrollSetting(data.employee_type, data.employee_id);
if (existing) {
return db.prepare(`
UPDATE payroll_settings SET
base_salary = ?, transport_allowance = ?, meal_allowance = ?,
phone_allowance = ?, other_allowance = ?, other_allowance_note = ?,
absence_deduction_per_day = ?, bonus_per_ticket = ?,
commission_percentage = ?, working_days_per_month = ?,
updated_at = (NOW_LOCAL())
WHERE employee_type = ? AND employee_id = ?
`).run(
parseInt(data.base_salary) || 0,
parseInt(data.transport_allowance) || 0,
parseInt(data.meal_allowance) || 0,
parseInt(data.phone_allowance) || 0,
parseInt(data.other_allowance) || 0,
data.other_allowance_note || '',
parseInt(data.absence_deduction_per_day) || 0,
parseInt(data.bonus_per_ticket) || 0,
parseFloat(data.commission_percentage) || 0,
parseInt(data.working_days_per_month) || 26,
data.employee_type,
data.employee_id
);
} else {
return db.prepare(`
INSERT INTO payroll_settings (
employee_type, employee_id,
base_salary, transport_allowance, meal_allowance,
phone_allowance, other_allowance, other_allowance_note,
absence_deduction_per_day, bonus_per_ticket,
commission_percentage, working_days_per_month
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.employee_type,
parseInt(data.employee_id),
parseInt(data.base_salary) || 0,
parseInt(data.transport_allowance) || 0,
parseInt(data.meal_allowance) || 0,
parseInt(data.phone_allowance) || 0,
parseInt(data.other_allowance) || 0,
data.other_allowance_note || '',
parseInt(data.absence_deduction_per_day) || 0,
parseInt(data.bonus_per_ticket) || 0,
parseFloat(data.commission_percentage) || 0,
parseInt(data.working_days_per_month) || 26
);
}
}
function deletePayrollSetting(employeeType, employeeId) {
return db.prepare(
'DELETE FROM payroll_settings WHERE employee_type = ? AND employee_id = ?'
).run(employeeType, employeeId);
}
// ─── EMPLOYEE LIST ───────────────────────────────────────────────────────────
function getAllEmployees() {
const employees = [];
const technicians = db.prepare('SELECT id, name, phone, is_active FROM technicians WHERE is_active = 1').all();
for (const t of technicians) {
const setting = getPayrollSetting('technician', t.id);
employees.push({ ...t, employee_type: 'technician', payroll: setting });
}
const cashiers = db.prepare('SELECT id, name, phone, is_active FROM cashiers WHERE is_active = 1').all();
for (const c of cashiers) {
const setting = getPayrollSetting('cashier', c.id);
employees.push({ ...c, employee_type: 'cashier', payroll: setting });
}
const collectors = db.prepare('SELECT id, name, phone, is_active FROM collectors WHERE is_active = 1').all();
for (const c of collectors) {
const setting = getPayrollSetting('collector', c.id);
employees.push({ ...c, employee_type: 'collector', payroll: setting });
}
return employees;
}
function getEmployeeName(employeeType, employeeId) {
const table = employeeType === 'technician' ? 'technicians'
: employeeType === 'cashier' ? 'cashiers'
: employeeType === 'collector' ? 'collectors' : null;
if (!table) return 'Unknown';
const row = db.prepare(`SELECT name FROM ${table} WHERE id = ?`).get(employeeId);
return row ? row.name : 'Unknown';
}
function getEmployeePhone(employeeType, employeeId) {
const table = employeeType === 'technician' ? 'technicians'
: employeeType === 'cashier' ? 'cashiers'
: employeeType === 'collector' ? 'collectors' : null;
if (!table) return '';
const row = db.prepare(`SELECT phone FROM ${table} WHERE id = ?`).get(employeeId);
return row ? (row.phone || '') : '';
}
// ─── PERFORMANCE DATA ────────────────────────────────────────────────────────
function getTicketsResolvedCount(technicianId, month, year) {
const monthStr = String(month).padStart(2, '0');
const yearStr = String(year);
const result = db.prepare(`
SELECT COUNT(*) as cnt FROM tickets
WHERE technician_id = ? AND status = 'resolved'
AND strftime('%Y', datetime(updated_at, 'localtime')) = ?
AND strftime('%m', datetime(updated_at, 'localtime')) = ?
`).get(technicianId, yearStr, monthStr);
return result ? result.cnt : 0;
}
function getCollectionAmount(collectorId, month, year) {
const monthStr = String(month).padStart(2, '0');
const yearStr = String(year);
const result = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total FROM collector_payment_requests
WHERE collector_id = ? AND status = 'approved'
AND strftime('%Y', datetime(created_at, 'localtime')) = ?
AND strftime('%m', datetime(created_at, 'localtime')) = ?
`).get(collectorId, yearStr, monthStr);
return result ? result.total : 0;
}
function getAttendanceData(employeeType, employeeId, month, year) {
const monthStr = String(month).padStart(2, '0');
const yearStr = String(year);
const summary = db.prepare(`
SELECT
COUNT(*) as total_days,
SUM(CASE WHEN status = 'checked_out' THEN 1 ELSE 0 END) as completed_days,
SUM(work_duration_minutes) as total_minutes,
SUM(CASE WHEN time(datetime(check_in_time, 'localtime')) > '08:30:00' THEN 1 ELSE 0 END) as late_days,
SUM(CASE WHEN work_duration_minutes > 480 THEN work_duration_minutes - 480 ELSE 0 END) as overtime_minutes
FROM attendance
WHERE employee_type = ? AND employee_id = ?
AND strftime('%Y', datetime(check_in_time, 'localtime')) = ?
AND strftime('%m', datetime(check_in_time, 'localtime')) = ?
`).get(employeeType, employeeId, yearStr, monthStr);
return {
workingDays: summary ? (summary.total_days || 0) : 0,
completedDays: summary ? (summary.completed_days || 0) : 0,
totalMinutes: summary ? (summary.total_minutes || 0) : 0,
lateDays: summary ? (summary.late_days || 0) : 0,
overtimeMinutes: summary ? (summary.overtime_minutes || 0) : 0
};
}
// ─── PAYROLL SLIP GENERATION ─────────────────────────────────────────────────
function generateSlip(employeeType, employeeId, month, year) {
const setting = getPayrollSetting(employeeType, employeeId);
if (!setting) {
throw new Error(`Pengaturan gaji belum diset untuk ${employeeType} ID ${employeeId}`);
}
// Cek apakah slip sudah ada
const existing = db.prepare(`
SELECT id FROM payroll_slips
WHERE employee_type = ? AND employee_id = ? AND period_month = ? AND period_year = ?
`).get(employeeType, employeeId, month, year);
if (existing) {
throw new Error(`Slip gaji sudah ada untuk periode ${month}/${year}. Hapus dulu jika ingin generate ulang.`);
}
const employeeName = getEmployeeName(employeeType, employeeId);
const attendance = getAttendanceData(employeeType, employeeId, month, year);
// Hitung hari absen
const absentDays = Math.max(0, setting.working_days_per_month - attendance.workingDays);
// Hitung bonus performa
let ticketsResolved = 0;
let ticketBonus = 0;
if (employeeType === 'technician' && setting.bonus_per_ticket > 0) {
ticketsResolved = getTicketsResolvedCount(employeeId, month, year);
ticketBonus = ticketsResolved * setting.bonus_per_ticket;
}
let collectionAmount = 0;
let collectionCommission = 0;
if (employeeType === 'collector' && setting.commission_percentage > 0) {
collectionAmount = getCollectionAmount(employeeId, month, year);
collectionCommission = Math.round(collectionAmount * (setting.commission_percentage / 100));
}
// Hitung overtime bonus (Rp 15.000 per jam lembur)
const overtimeHours = Math.round((attendance.overtimeMinutes / 60) * 10) / 10;
const overtimeBonus = Math.round(overtimeHours * 15000);
// Hitung potongan
const absenceDeduction = absentDays * setting.absence_deduction_per_day;
const lateDeduction = attendance.lateDays * Math.round(setting.absence_deduction_per_day * 0.25); // 25% potongan per hari terlambat
// Total pendapatan
const grossSalary = setting.base_salary
+ setting.transport_allowance
+ setting.meal_allowance
+ setting.phone_allowance
+ setting.other_allowance
+ ticketBonus
+ collectionCommission
+ overtimeBonus;
// Total potongan
const totalDeductions = absenceDeduction + lateDeduction;
// Gaji bersih
const netSalary = Math.max(0, grossSalary - totalDeductions);
const result = db.prepare(`
INSERT INTO payroll_slips (
employee_type, employee_id, employee_name, period_month, period_year,
base_salary, transport_allowance, meal_allowance, phone_allowance,
other_allowance, other_allowance_note,
working_days, absent_days, late_days, overtime_hours,
total_tickets_resolved, total_collection_amount,
ticket_bonus, collection_commission, overtime_bonus,
absence_deduction, late_deduction, other_deduction, other_deduction_note,
gross_salary, total_deductions, net_salary, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft')
`).run(
employeeType, employeeId, employeeName, month, year,
setting.base_salary, setting.transport_allowance, setting.meal_allowance,
setting.phone_allowance, setting.other_allowance, setting.other_allowance_note || '',
attendance.workingDays, absentDays, attendance.lateDays, overtimeHours,
ticketsResolved, collectionAmount,
ticketBonus, collectionCommission, overtimeBonus,
absenceDeduction, lateDeduction, 0, '',
grossSalary, totalDeductions, netSalary
);
return result.lastInsertRowid;
}
function generateAllSlips(month, year) {
const employees = getAllEmployees();
let generated = 0;
let skipped = 0;
const errors = [];
for (const emp of employees) {
if (!emp.payroll) {
skipped++;
continue;
}
try {
generateSlip(emp.employee_type, emp.id, month, year);
generated++;
} catch (e) {
if (e.message.includes('Slip gaji sudah ada')) {
skipped++;
} else {
errors.push(`${emp.name}: ${e.message}`);
}
}
}
return { generated, skipped, errors };
}
// ─── PAYROLL SLIP CRUD ───────────────────────────────────────────────────────
function getSlipById(id) {
return db.prepare('SELECT * FROM payroll_slips WHERE id = ?').get(id);
}
function getSlipsByPeriod(month, year) {
return db.prepare(`
SELECT * FROM payroll_slips
WHERE period_month = ? AND period_year = ?
ORDER BY employee_type, employee_name
`).all(month, year);
}
function getSlipsByEmployee(employeeType, employeeId) {
return db.prepare(`
SELECT * FROM payroll_slips
WHERE employee_type = ? AND employee_id = ?
ORDER BY period_year DESC, period_month DESC
`).all(employeeType, employeeId);
}
function updateSlipDeductions(id, otherDeduction, otherDeductionNote) {
const slip = getSlipById(id);
if (!slip) throw new Error('Slip tidak ditemukan');
if (slip.status !== 'draft') throw new Error('Hanya slip draft yang bisa diedit');
const od = parseInt(otherDeduction) || 0;
const newTotalDeductions = slip.absence_deduction + slip.late_deduction + od;
const newNetSalary = Math.max(0, slip.gross_salary - newTotalDeductions);
return db.prepare(`
UPDATE payroll_slips SET
other_deduction = ?, other_deduction_note = ?,
total_deductions = ?, net_salary = ?
WHERE id = ?
`).run(od, otherDeductionNote || '', newTotalDeductions, newNetSalary, id);
}
function approveSlip(id) {
const slip = getSlipById(id);
if (!slip) throw new Error('Slip tidak ditemukan');
if (slip.status !== 'draft') throw new Error('Hanya slip draft yang bisa di-approve');
return db.prepare(`
UPDATE payroll_slips SET status = 'approved', approved_at = (NOW_LOCAL()) WHERE id = ?
`).run(id);
}
function markSlipPaid(id) {
const slip = getSlipById(id);
if (!slip) throw new Error('Slip tidak ditemukan');
if (slip.status !== 'approved') throw new Error('Hanya slip yang sudah approved yang bisa ditandai paid');
const result = db.prepare(`
UPDATE payroll_slips SET status = 'paid', paid_at = (NOW_LOCAL()) WHERE id = ?
`).run(id);
// Auto Insert ke Tabel Pengeluaran (Expenses)
try {
db.prepare(`
INSERT INTO expenses (date, category, amount, description, payment_method, recorded_by_name)
VALUES (date('now', 'localtime'), 'Gaji & Tunjangan', ?, ?, 'transfer', 'Sistem (Payroll)')
`).run(slip.net_salary, `Gaji ${slip.employee_name} (Periode ${slip.period_month}/${slip.period_year})`);
} catch (e) {
logger.error('Gagal mencatat expense dari slip gaji: ' + e.message);
}
return result;
}
function bulkApprove(month, year) {
return db.prepare(`
UPDATE payroll_slips SET status = 'approved', approved_at = (NOW_LOCAL())
WHERE period_month = ? AND period_year = ? AND status = 'draft'
`).run(month, year);
}
function bulkMarkPaid(month, year) {
const slipsToPay = db.prepare(`SELECT * FROM payroll_slips WHERE period_month = ? AND period_year = ? AND status = 'approved'`).all(month, year);
const result = db.prepare(`
UPDATE payroll_slips SET status = 'paid', paid_at = (NOW_LOCAL())
WHERE period_month = ? AND period_year = ? AND status = 'approved'
`).run(month, year);
// Auto Insert ke Tabel Pengeluaran (Expenses) untuk semua slip
try {
const stmt = db.prepare(`
INSERT INTO expenses (date, category, amount, description, payment_method, recorded_by_name)
VALUES (date('now', 'localtime'), 'Gaji & Tunjangan', ?, ?, 'transfer', 'Sistem (Payroll)')
`);
for (const slip of slipsToPay) {
stmt.run(slip.net_salary, `Gaji ${slip.employee_name} (Periode ${slip.period_month}/${slip.period_year})`);
}
} catch (e) {
logger.error('Gagal mencatat expense bulk slip gaji: ' + e.message);
}
return result;
}
function deleteSlip(id) {
const slip = getSlipById(id);
if (!slip) throw new Error('Slip tidak ditemukan');
if (slip.status === 'paid') throw new Error('Slip yang sudah paid tidak bisa dihapus');
return db.prepare('DELETE FROM payroll_slips WHERE id = ?').run(id);
}
function deleteSlipsByPeriod(month, year) {
return db.prepare(`
DELETE FROM payroll_slips
WHERE period_month = ? AND period_year = ? AND status = 'draft'
`).run(month, year);
}
// ─── STATISTICS ──────────────────────────────────────────────────────────────
function getPayrollSummary(month, year) {
const result = db.prepare(`
SELECT
COUNT(*) as total_slips,
SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END) as draft_count,
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid_count,
COALESCE(SUM(gross_salary), 0) as total_gross,
COALESCE(SUM(total_deductions), 0) as total_deductions,
COALESCE(SUM(net_salary), 0) as total_net
FROM payroll_slips
WHERE period_month = ? AND period_year = ?
`).get(month, year);
return result || {
total_slips: 0, draft_count: 0, approved_count: 0, paid_count: 0,
total_gross: 0, total_deductions: 0, total_net: 0
};
}
function getAvailablePeriods() {
return db.prepare(`
SELECT DISTINCT period_month, period_year
FROM payroll_slips
ORDER BY period_year DESC, period_month DESC
LIMIT 24
`).all();
}
module.exports = {
EMPLOYEE_TYPES,
getPayrollSetting, getAllPayrollSettings, upsertPayrollSetting, deletePayrollSetting,
getAllEmployees, getEmployeeName, getEmployeePhone,
getTicketsResolvedCount, getCollectionAmount, getAttendanceData,
generateSlip, generateAllSlips,
getSlipById, getSlipsByPeriod, getSlipsByEmployee,
updateSlipDeductions, approveSlip, markSlipPaid,
bulkApprove, bulkMarkPaid,
deleteSlip, deleteSlipsByPeriod,
getPayrollSummary, getAvailablePeriods
};
+106
View File
@@ -0,0 +1,106 @@
/**
* Service: PDF Slip Gaji (PDFKit, A4 portrait) — tema bersama (pdfBranding).
*/
const PDFDocument = require('pdfkit');
const fs = require('fs');
const { THEME, resolveLogoPath, companyInfo, fmtRp } = require('./pdfBranding');
const MNS = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
function generatePayslipPdfBuffer(slip, settings = {}) {
return new Promise((resolve, reject) => {
try {
const ci = companyInfo(settings);
const doc = new PDFDocument({ size: 'A4', margin: 0, info: { Title: `Slip Gaji — ${slip.employee_name}`, Author: ci.name, Subject: 'Slip Gaji' } });
const bufs = [];
doc.on('data', b => bufs.push(b)); doc.on('end', () => resolve(Buffer.concat(bufs))); doc.on('error', reject);
const { INK, MUTED, LINE, SOFT, GREEN, RED, AMBER, BLUE, ACCENT, LIGHT } = THEME;
const LEFT = 40, RIGHT = 555, W = RIGHT - LEFT;
// Header
doc.rect(0, 0, 595.28, 74).fill(INK); doc.rect(0, 74, 595.28, 3).fill(ACCENT);
let y = 20, nx = LEFT;
const lp = resolveLogoPath(settings);
if (lp && fs.existsSync(lp)) { try { doc.image(lp, LEFT, y, { fit: [110, 34] }); nx = LEFT + 122; } catch (e) {} }
doc.fillColor('#ffffff').fontSize(15).font('Helvetica-Bold').text(ci.name, nx, y, { width: 205, lineBreak: false });
doc.fillColor(LIGHT).fontSize(8).font('Helvetica').text(ci.address, nx, y + 19, { width: 205, height: 11, ellipsis: true });
doc.fillColor('#ffffff').fontSize(16).font('Helvetica-Bold').text('SLIP GAJI', LEFT, y, { width: W, align: 'right' });
doc.fillColor(ACCENT).fontSize(9).font('Helvetica-Bold').text(`Periode ${MNS[(slip.period_month || 1) - 1]} ${slip.period_year || ''}`, LEFT, y + 23, { width: W, align: 'right' });
y = 92;
doc.fillColor(MUTED).fontSize(8).font('Helvetica').text(`Dicetak: ${new Date().toLocaleString('id-ID')}`, LEFT, y);
// Info karyawan
y += 16;
const role = slip.employee_type === 'technician' ? 'Teknisi' : slip.employee_type === 'cashier' ? 'Kasir' : 'Kolektor';
doc.roundedRect(LEFT, y, W, 58, 8).fillAndStroke(SOFT, LINE);
doc.rect(LEFT, y, 4, 58).fill(ACCENT);
doc.fillColor(MUTED).fontSize(7.5).font('Helvetica-Bold').text('KARYAWAN', LEFT + 16, y + 10);
doc.fillColor(INK).fontSize(13).font('Helvetica-Bold').text(slip.employee_name || '-', LEFT + 16, y + 22);
doc.fillColor(MUTED).fontSize(8.5).font('Helvetica').text(`Jabatan: ${role} • Hari kerja: ${slip.working_days || 0} • Absen: ${slip.absent_days || 0} • Terlambat: ${slip.late_days || 0}`, LEFT + 16, y + 40);
doc.fillColor(INK).fontSize(9).font('Helvetica-Bold').text(`Status: ${String(slip.status || '-').toUpperCase()}`, LEFT, y + 22, { width: W - 16, align: 'right' });
y += 74;
const row = (label, value, opt = {}) => {
const { bold = false, color = null, section = false } = opt;
if (section) {
doc.rect(LEFT, y, W, 18).fill('#eef2f6');
doc.fillColor(INK).fontSize(8.5).font('Helvetica-Bold').text(label, LEFT + 8, y + 5);
y += 18; return;
}
doc.fillColor(bold ? INK : MUTED).fontSize(bold ? 10 : 9).font(bold ? 'Helvetica-Bold' : 'Helvetica').text(label, LEFT + 8, y + 2, { width: W - 180 });
doc.fillColor(color || INK).fontSize(bold ? 10 : 9).font(bold ? 'Helvetica-Bold' : 'Helvetica').text(value, LEFT + W - 172, y + 2, { width: 164, align: 'right' });
doc.moveTo(LEFT, y + 16).lineTo(RIGHT, y + 16).strokeColor(LINE).stroke();
y += 17;
};
// Pendapatan
row('PENDAPATAN', '', { section: true });
row('Gaji Pokok', fmtRp(slip.base_salary));
if (slip.transport_allowance) row('Tunjangan Transport', fmtRp(slip.transport_allowance));
if (slip.meal_allowance) row('Tunjangan Makan', fmtRp(slip.meal_allowance));
if (slip.phone_allowance) row('Tunjangan Pulsa', fmtRp(slip.phone_allowance));
if (slip.other_allowance) row(`Tunjangan Lain (${slip.other_allowance_note || '-'})`, fmtRp(slip.other_allowance));
if (slip.ticket_bonus) row(`Bonus Tiket (${slip.total_tickets_resolved || 0} tiket)`, '+ ' + fmtRp(slip.ticket_bonus), { color: GREEN });
if (slip.collection_commission) row('Komisi Tagihan', '+ ' + fmtRp(slip.collection_commission), { color: GREEN });
if (slip.overtime_bonus) row(`Lembur (${slip.overtime_hours || 0} jam)`, '+ ' + fmtRp(slip.overtime_bonus), { color: GREEN });
row('Total Pendapatan', fmtRp(slip.gross_salary), { bold: true });
// Potongan
if (Number(slip.total_deductions || 0) > 0) {
y += 4; row('POTONGAN', '', { section: true });
if (slip.absence_deduction) row(`Potongan Absen (${slip.absent_days || 0} hari)`, '- ' + fmtRp(slip.absence_deduction), { color: RED });
if (slip.late_deduction) row(`Potongan Terlambat (${slip.late_days || 0} hari)`, '- ' + fmtRp(slip.late_deduction), { color: RED });
if (slip.other_deduction) row(`Potongan Lain (${slip.other_deduction_note || '-'})`, '- ' + fmtRp(slip.other_deduction), { color: RED });
row('Total Potongan', '- ' + fmtRp(slip.total_deductions), { bold: true, color: RED });
}
// Gaji bersih
y += 8;
doc.roundedRect(LEFT, y, W, 34, 8).fill(INK);
doc.fillColor('#ffffff').fontSize(11).font('Helvetica-Bold').text('GAJI BERSIH', LEFT + 14, y + 11);
doc.fillColor(ACCENT).fontSize(16).font('Helvetica-Bold').text(fmtRp(slip.net_salary), LEFT, y + 8, { width: W - 16, align: 'right' });
y += 48;
if (slip.notes) { doc.fillColor(MUTED).fontSize(8.5).font('Helvetica').text(`Catatan: ${slip.notes}`, LEFT, y, { width: W }); }
// Tanda tangan
const sy = 730;
doc.fillColor(MUTED).fontSize(8.5).font('Helvetica').text('Karyawan,', LEFT, sy, { width: 180, align: 'center' });
doc.moveTo(LEFT + 10, sy + 40).lineTo(LEFT + 170, sy + 40).strokeColor(INK).stroke();
doc.fillColor(INK).fontSize(9).font('Helvetica-Bold').text(slip.employee_name || '-', LEFT, sy + 44, { width: 180, align: 'center' });
doc.fillColor(MUTED).fontSize(8.5).font('Helvetica').text('Admin / Pimpinan,', RIGHT - 180, sy, { width: 180, align: 'center' });
doc.moveTo(RIGHT - 170, sy + 40).lineTo(RIGHT - 10, sy + 40).strokeColor(INK).stroke();
doc.fillColor(INK).fontSize(9).font('Helvetica-Bold').text(ci.manager || 'Admin', RIGHT - 180, sy + 44, { width: 180, align: 'center' });
// Footer
doc.moveTo(LEFT, sy - 16).lineTo(RIGHT, sy - 16).strokeColor(LINE).stroke();
doc.fillColor(MUTED).fontSize(7.5).text(`Dokumen dibuat otomatis oleh ${ci.name} • ${new Date().toLocaleString('id-ID')}`, LEFT, sy - 12, { width: W });
doc.end();
} catch (e) { reject(e); }
});
}
module.exports = { generatePayslipPdfBuffer };
+57
View File
@@ -0,0 +1,57 @@
/**
* pdfBranding.js — Tema & branding bersama untuk semua PDF (pdfkit).
* Dipakai oleh: pdfInvoiceService.js, financeReportPdfService.js.
* Nama & logo otomatis dari settings (company_header / company_logo -> public /img/logo.png).
*/
const path = require('path');
const fs = require('fs');
const THEME = {
INK: '#0f172a', // header band / teks utama
HEAD: '#0f172a', // alias header band
ACCENT: '#ADFF2F', // aksen lime (garis header / highlight)
ACCENT_TXT: '#065f46',
MUTED: '#64748b',
LINE: '#e2e8f0',
SOFT: '#f8fafc',
GREEN: '#16a34a',
RED: '#e11d48',
AMBER: '#d97706',
BLUE: '#0284c7',
LIGHT: '#94a3b8',
ON_HEAD: '#ffffff',
};
// Resolusi logo: pakai settings.company_logo bila file lokal ada; fallback /img/logo.png
function resolveLogoPath(settings) {
const cands = [];
try {
const raw = String((settings && (settings.logo_invoice || settings.logo_portal || settings.company_logo)) || '').trim();
if (raw && !/^https?:\/\//i.test(raw) && !raw.startsWith('data:')) {
let p = raw.split('?')[0].split('#')[0].replace(/^\/+/, '');
cands.push(path.join(__dirname, '../public', p));
}
} catch (e) {}
cands.push(path.join(__dirname, '../public/img/logo.png'));
for (const c of cands) { try { if (fs.existsSync(c)) return c; } catch (e) {} }
return null;
}
function fmtRp(n) { return 'Rp ' + Math.round(Number(n) || 0).toLocaleString('id-ID'); }
function companyInfo(settings) {
const s = settings || {};
const phone = (Array.isArray(s.whatsapp_admin_numbers) && s.whatsapp_admin_numbers[0])
? '+' + String(s.whatsapp_admin_numbers[0]).replace(/^0/, '62')
: (s.company_phone || '-');
return {
name: s.company_header || 'OPEN-ISP',
sub: s.company_subheader || 'PT Artha Mega Data',
address: s.company_address || 'Pusat Layanan Internet',
phone,
email: s.company_email || '',
manager: s.company_manager || 'Bagian Keuangan',
};
}
module.exports = { THEME, resolveLogoPath, fmtRp, companyInfo };
+213
View File
@@ -0,0 +1,213 @@
/**
* Service: PDF Invoice Generator (Redesign — profesional)
* PDFKit. Dipakai untuk invoice pelanggan (A4). Semua PDF memakai layout ini.
*/
const PDFDocument = require('pdfkit');
const { THEME, resolveLogoPath } = require('./pdfBranding');
const path = require('path');
const fs = require('fs');
const MNS = ['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'];
function fmtRp(n) {
return 'Rp ' + Number(n || 0).toLocaleString('id-ID');
}
function generateInvoicePdfBuffer(invoice, customer, settings = {}) {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margin: 0,
info: {
Title: `Invoice #INV-${String(invoice.id).padStart(4, '0')}`,
Author: settings.company_header || 'OPEN-ISP',
Subject: 'Invoice Pembayaran Internet'
}
});
const buffers = [];
doc.on('data', b => buffers.push(b));
doc.on('end', () => resolve(Buffer.concat(buffers)));
doc.on('error', err => reject(err));
const companyName = settings.company_header || 'OPEN-ISP';
const companySub = settings.company_subheader || 'PT Artha Mega Data';
const companyAddress = settings.company_address || 'Pusat Layanan Internet Terpercaya';
const companyPhone = (settings.whatsapp_admin_numbers && settings.whatsapp_admin_numbers.length > 0)
? '+' + String(settings.whatsapp_admin_numbers[0]).replace(/^0/, '62')
: (settings.company_phone || '-');
const companyEmail = settings.company_email || '';
const managerName = settings.company_manager || 'Admin Pusat';
const year = new Date().getFullYear();
const invNo = `INV-${String(invoice.id).padStart(4, '0')}-${year}`;
const isPaid = (invoice.status === 'paid');
const isVoid = (invoice.status === 'void');
const periodStr = `${MNS[(invoice.period_month || 1) - 1]} ${invoice.period_year || year}`;
// Palet
const BRAND = THEME.HEAD;
const BRAND2 = THEME.ACCENT;
const INK = THEME.INK;
const MUTED = THEME.MUTED;
const SOFT = THEME.SOFT;
const LINE = THEME.LINE;
const GREEN = THEME.GREEN;
const RED = THEME.RED;
const statusColor = isVoid ? MUTED : (isPaid ? GREEN : RED);
const statusText = isVoid ? 'DIBATALKAN' : (isPaid ? 'LUNAS' : 'BELUM BAYAR');
const LEFT = 40, RIGHT = 555, W = RIGHT - LEFT;
// ── Header band ──
doc.rect(0, 0, 595.28, 96).fill(BRAND);
doc.rect(0, 92, 595.28, 4).fill(BRAND2);
// logo / avatar
let y = 24;
const logoPath = resolveLogoPath(settings);
let logoDrawn = false;
if (logoPath && fs.existsSync(logoPath)) {
try { doc.image(logoPath, LEFT, y, { fit: [120, 38] }); logoDrawn = true; } catch (e) {}
}
const nameX = logoDrawn ? LEFT + 134 : LEFT;
if (!logoDrawn) {
doc.circle(LEFT + 16, y + 16, 16).fill('#ffffff');
doc.fillColor(BRAND).fontSize(16).font('Helvetica-Bold').text(companyName.charAt(0).toUpperCase(), LEFT, y + 8, { width: 32, align: 'center' });
}
doc.fillColor('#ffffff').fontSize(16).font('Helvetica-Bold').text(companyName, nameX, y, { lineBreak: false });
doc.fillColor('#cbd5e1').fontSize(8.5).font('Helvetica').text(companySub, nameX, y + 21, { lineBreak: false });
doc.fillColor('#ffffff').fontSize(8.5).font('Helvetica').text(`Telp/WA: ${companyPhone}${companyEmail ? ' • ' + companyEmail : ''}`, nameX, y + 33, { lineBreak: false });
// Judul di kanan
doc.fillColor('#ffffff').fontSize(24).font('Helvetica-Bold').text('INVOICE', LEFT, y, { width: W, align: 'right' });
doc.fillColor('#cbd5e1').fontSize(9).font('Helvetica-Bold').text(`# ${invNo}`, LEFT, y + 30, { width: W, align: 'right' });
// ── Status ribbon ──
y = 112;
doc.roundedRect(RIGHT - 132, y, 132, 22, 11).fillAndStroke(statusColor, statusColor);
doc.fillColor('#ffffff').fontSize(9.5).font('Helvetica-Bold').text(statusText, RIGHT - 132, y + 6, { width: 132, align: 'center' });
// Tanggal terbit kanan
const issueDate = isPaid && invoice.paid_at ? String(invoice.paid_at).slice(0, 10) : new Date().toISOString().slice(0, 10);
doc.fillColor(MUTED).fontSize(8).font('Helvetica').text('Tanggal', LEFT, y + 2, { width: W - 150, align: 'right' });
doc.fillColor(INK).fontSize(10).font('Helvetica-Bold').text(issueDate, LEFT, y + 11, { width: W - 150, align: 'right' });
// ── Bill To & Customer Info ──
y = 150;
const cardH = 92;
doc.roundedRect(LEFT, y, 300, cardH, 8).fillAndStroke(SOFT, LINE);
doc.rect(LEFT, y, 4, cardH).fill(BRAND);
doc.fillColor(MUTED).fontSize(7.5).font('Helvetica-Bold').text('DITAGIHKAN KEPADA', LEFT + 16, y + 12);
doc.fillColor(INK).fontSize(13).font('Helvetica-Bold').text(customer.name || 'Pelanggan', LEFT + 16, y + 24, { width: 270 });
const meta = [];
if (customer.customer_code) meta.push(`ID: ${customer.customer_code}`);
if (customer.phone) meta.push(`HP: ${customer.phone}`);
if (meta.length) doc.fillColor(MUTED).fontSize(8.5).font('Helvetica').text(meta.join(' • '), LEFT + 16, y + 44, { width: 270 });
if (customer.address) doc.fillColor(MUTED).fontSize(8.5).font('Helvetica').text(`Alamat: ${customer.address}`, LEFT + 16, y + 58, { width: 270, height: 28 });
// Ringkasan kanan
doc.roundedRect(LEFT + 315, y, 200, cardH, 8).fillAndStroke(SOFT, LINE);
doc.fillColor(MUTED).fontSize(7.5).font('Helvetica-Bold').text('RINGKASAN', LEFT + 331, y + 12);
const lineRs = (label, val, dy, bold) => {
doc.fillColor(MUTED).fontSize(8.5).font('Helvetica').text(label, LEFT + 331, y + dy, { width: 100 });
doc.fillColor(bold ? INK : MUTED).fontSize(9).font(bold ? 'Helvetica-Bold' : 'Helvetica').text(val, LEFT + 411, y + dy, { width: 90, align: 'right' });
};
lineRs('Periode', periodStr, 26);
lineRs('Paket', String(customer.package_name || '-').slice(0, 22), 40);
lineRs('Status', statusText, 54, true);
// ── Tabel layanan ──
y = 262;
doc.fillColor(MUTED).fontSize(8).font('Helvetica-Bold').text('RINCIAN LAYANAN', LEFT, y);
y += 12;
doc.roundedRect(LEFT, y, W, 24, 6).fill(BRAND);
doc.fillColor('#ffffff').fontSize(8.5).font('Helvetica-Bold');
doc.text('#', LEFT + 12, y + 8, { width: 24 });
doc.text('Deskripsi', LEFT + 40, y + 8, { width: 300 });
doc.text('Jumlah', LEFT + W - 130, y + 8, { width: 118, align: 'right' });
y += 24;
const pkgName = customer.package_name || 'Paket Internet';
const amount = Number(invoice.amount || 0);
const rowH = 40;
doc.rect(LEFT, y, W, rowH).fill('#ffffff').strokeColor(LINE).stroke();
doc.fillColor(INK).fontSize(9.5).font('Helvetica-Bold').text('01', LEFT + 12, y + 9);
doc.fillColor(INK).fontSize(9.5).font('Helvetica-Bold').text(`Layanan Internet — ${pkgName}`, LEFT + 40, y + 8);
doc.fillColor(MUTED).fontSize(8).font('Helvetica').text(`Langganan bulanan • ${periodStr}`, LEFT + 40, y + 22);
doc.fillColor(INK).fontSize(10).font('Helvetica-Bold').text(fmtRp(amount), LEFT + W - 130, y + 12, { width: 118, align: 'right' });
// Watermark
doc.save();
doc.fillColor(statusColor).opacity(0.07).fontSize(64).font('Helvetica-Bold');
doc.rotate(-18, { origin: [297, y + 40] });
doc.text(statusText, 100, y + 6, { width: 400, align: 'center' });
doc.restore();
// ── Totals ──
y += rowH + 16;
const beforeDisc = Number(invoice.amount_before_discount || amount);
const disc = Number(invoice.discount_amount || 0);
const tax = Number(invoice.tax_amount || 0);
const total = amount;
const boxX = LEFT + 300, boxW = W - 300;
let ty = y;
const totalLine = (label, val, bold, color) => {
doc.fillColor(bold ? INK : MUTED).fontSize(bold ? 10.5 : 9).font(bold ? 'Helvetica-Bold' : 'Helvetica').text(label, boxX, ty, { width: boxW - 120 });
doc.fillColor(color || (bold ? INK : MUTED)).fontSize(bold ? 11 : 9).font(bold ? 'Helvetica-Bold' : 'Helvetica').text(val, boxX + boxW - 120, ty, { width: 120, align: 'right' });
ty += bold ? 18 : 15;
};
doc.fillColor(MUTED).fontSize(8).font('Helvetica-Bold').text('PEMBAYARAN', boxX, ty); ty += 14;
totalLine('Subtotal', fmtRp(beforeDisc));
if (disc > 0) totalLine('Diskon', '- ' + fmtRp(disc), false, GREEN);
if (tax > 0) totalLine('Pajak (PPN/USO)', fmtRp(tax));
doc.moveTo(boxX, ty).lineTo(boxX + boxW, ty).strokeColor(LINE).stroke(); ty += 6;
doc.roundedRect(boxX - 10, ty - 4, boxW + 10, 30, 8).fill(BRAND);
doc.fillColor('#ffffff').fontSize(10).font('Helvetica-Bold').text('TOTAL', boxX, ty + 5, { width: boxW - 120 });
doc.fillColor('#ffffff').fontSize(13).font('Helvetica-Bold').text(fmtRp(total), boxX + boxW - 130, ty + 3, { width: 130, align: 'right' });
ty += 40;
// Info metode bayar (kiri)
let my = y;
doc.fillColor(MUTED).fontSize(8).font('Helvetica-Bold').text('STATUS PEMBAYARAN', LEFT, my); my += 14;
const payRows = [
['Metode', invoice.payment_gateway || invoice.paid_by_name || (isPaid ? 'Manual' : '-')],
['Waktu', isPaid && invoice.paid_at ? String(invoice.paid_at).replace('T', ' ').slice(0, 19) : '-'],
['Kasir', invoice.paid_by_name || 'Sistem']
];
for (const [k, v] of payRows) {
doc.fillColor(MUTED).fontSize(8.5).font('Helvetica').text(k, LEFT, my, { width: 80 });
doc.fillColor(INK).fontSize(8.5).font('Helvetica-Bold').text(String(v).slice(0, 40), LEFT + 80, my, { width: 190 });
my += 15;
}
// ── Catatan ──
let ny = Math.max(ty, my) + 6;
doc.roundedRect(LEFT, ny, W, 46, 8).fillAndStroke('#fefce8', '#fde68a');
doc.fillColor('#92400e').fontSize(8.5).font('Helvetica-Bold').text('Catatan', LEFT + 12, ny + 8);
const noteText = invoice.notes
? String(invoice.notes).replace(/\n+/g, ' ').slice(0, 200)
: (isPaid ? 'Pembayaran telah diterima. Simpan dokumen ini sebagai bukti pembayaran yang sah.' : 'Mohon lakukan pembayaran sebelum tanggal jatuh tempo.');
doc.fillColor('#92400e').fontSize(8).font('Helvetica').text(noteText, LEFT + 12, ny + 20, { width: W - 24, height: 22 });
// ── Footer ──
const fy = 770;
doc.moveTo(LEFT, fy - 12).lineTo(RIGHT, fy - 12).strokeColor(LINE).stroke();
doc.fillColor(INK).fontSize(9).font('Helvetica-Bold').text(companyName, LEFT, fy);
doc.fillColor(MUTED).fontSize(8).font('Helvetica').text(companyAddress, LEFT, fy + 13, { width: 330, height: 22 });
doc.fillColor(MUTED).fontSize(7.5).text(`Dokumen dibuat otomatis • ${new Date().toLocaleString('id-ID')}`, LEFT, fy + 40);
doc.fillColor(MUTED).fontSize(8).font('Helvetica').text('Hormat Kami,', 430, fy, { width: 125, align: 'center' });
doc.moveTo(430, fy + 34).lineTo(555, fy + 34).strokeColor(INK).stroke();
doc.fillColor(INK).fontSize(9).font('Helvetica-Bold').text(managerName, 430, fy + 38, { width: 125, align: 'center' });
doc.fillColor(MUTED).fontSize(7.5).font('Helvetica').text('Manajer Operasional', 430, fy + 49, { width: 125, align: 'center' });
doc.end();
} catch (err) {
reject(err);
}
});
}
module.exports = { generateInvoicePdfBuffer };
+812
View File
@@ -0,0 +1,812 @@
/**
* Service RADIUS Server (Authentication & Accounting UDP Service)
* Menggunakan SQLite database billing.db & modul radiusPacket
*/
const dgram = require('dgram');
const db = require('../config/database');
const { getSetting } = require('../config/settingsManager');
const { logger } = require('../config/logger');
const {
CODES,
ATTR_TYPES,
MIKROTIK_VENDOR_ID,
MIKROTIK_VSAS,
decodePacket,
encodeResponsePacket,
verifyChapPassword
} = require('../utils/radiusPacket');
let authSocket = null;
let acctSocket = null;
let isRunning = false;
/**
* Mencari Secret NAS berdasarkan IP NAS
*/
function getNasSecret(nasIp) {
const defaultSecret = getSetting('radius_secret', 'secret123');
const cleanIp = String(nasIp || '').replace(/^::ffff:/, '').trim();
try {
const nasRow = db.prepare(`
SELECT secret FROM radius_nas
WHERE is_active = 1 AND (nasname = ? OR nasname = '0.0.0.0' OR nasname = '0.0.0.0/0')
ORDER BY CASE WHEN nasname = ? THEN 1 ELSE 2 END ASC, id DESC LIMIT 1
`).get(cleanIp, cleanIp);
if (nasRow && nasRow.secret) {
return nasRow.secret;
}
} catch (err) {
logger.error(`[RADIUS] Error getNasSecret: ${err.message}`);
}
return defaultSecret;
}
/**
* Mencari data pelanggan / pengguna dari SQLite DB
*/
function findUserCredentials(username) {
const cleanUsername = String(username || '').trim();
if (!cleanUsername) return null;
// 1. Cek tabel customers (pppoe_username atau name atau phone)
try {
const cust = db.prepare(`
SELECT c.id, c.name, c.pppoe_username, c.pppoe_password, c.hotspot_username, c.hotspot_password,
c.status, c.static_ip, c.pppoe_remote_address, c.package_id,
p.name as package_name, p.speed_up, p.speed_down, p.speed_up_upto, p.speed_down_upto, p.pool_name as pool_name
FROM customers c
LEFT JOIN packages p ON p.id = c.package_id
WHERE c.pppoe_username = ? OR c.hotspot_username = ? OR c.name = ? OR c.phone = ?
ORDER BY
CASE
WHEN c.pppoe_username = ? THEN 1
WHEN c.hotspot_username = ? THEN 2
ELSE 3
END ASC
LIMIT 1
`).get(cleanUsername, cleanUsername, cleanUsername, cleanUsername, cleanUsername, cleanUsername);
if (cust) {
// Pilih secret sesuai jenis username (PPPoE/Hotspot), fallback pppoe_users (kompat)
let secret = '';
if (cust.pppoe_username === cleanUsername) {
secret = cust.pppoe_password || '';
} else if (cust.hotspot_username === cleanUsername) {
secret = cust.hotspot_password || '';
} else {
secret = cust.pppoe_password || cust.hotspot_password || '';
}
if (!secret) {
try {
const pppoeUser = db.prepare(`SELECT secret FROM pppoe_users WHERE username = ? LIMIT 1`).get(cleanUsername);
if (pppoeUser) secret = pppoeUser.secret || '';
} catch (e) {}
}
const staticIp = (cust.static_ip && String(cust.static_ip).trim()) || (cust.pppoe_remote_address && String(cust.pppoe_remote_address).trim()) || '';
return {
type: 'customer',
id: cust.id,
username: cleanUsername,
secret: secret,
status: cust.status,
staticIp: staticIp,
speedUp: cust.speed_up || 0,
speedDown: cust.speed_down || 0,
speedUpUpto: cust.speed_up_upto || 0,
speedDownUpto: cust.speed_down_upto || 0,
packageName: cust.package_name || '',
poolName: cust.pool_name || ''
};
}
} catch (err) {
logger.error(`[RADIUS] Error findUserCredentials customers: ${err.message}`);
}
// 2. Cek tabel pppoe_users (jika ada)
try {
const pppoe = db.prepare(`
SELECT pu.id, pu.customer_id, pu.username, pu.secret, pu.status, pu.profile_name,
c.status as customer_status, c.static_ip,
p.speed_up, p.speed_down, p.speed_up_upto, p.speed_down_upto
FROM pppoe_users pu
LEFT JOIN customers c ON c.id = pu.customer_id
LEFT JOIN packages p ON p.id = c.package_id
WHERE pu.username = ? LIMIT 1
`).get(cleanUsername);
if (pppoe) {
const finalStatus = (pppoe.customer_status === 'suspended' || pppoe.status === 'disabled') ? 'suspended' : 'active';
return {
type: 'pppoe',
id: pppoe.id,
username: pppoe.username,
secret: pppoe.secret,
status: finalStatus,
staticIp: pppoe.static_ip,
speedUp: pppoe.speed_up || 0,
speedDown: pppoe.speed_down || 0,
speedUpUpto: pppoe.speed_up_upto || 0,
speedDownUpto: pppoe.speed_down_upto || 0,
packageName: pppoe.profile_name || ''
};
}
} catch (err) {
// Tabel pppoe_users mungkin tidak ada di skema tertentu
}
// 3. Cek tabel vouchers (Voucher Hotspot/PPPoE)
try {
const voucher = db.prepare(`
SELECT code, password, profile_name, status FROM vouchers WHERE code = ? LIMIT 1
`).get(cleanUsername);
if (voucher) {
return {
type: 'voucher',
id: voucher.code,
username: voucher.code,
secret: voucher.password,
status: voucher.status === 'used' || voucher.status === 'expired' ? 'suspended' : 'active',
speedUp: 0,
speedDown: 0,
speedUpUpto: 0,
speedDownUpto: 0,
packageName: voucher.profile_name || ''
};
}
} catch (err) {
logger.error(`[RADIUS] Error findUserCredentials vouchers: ${err.message}`);
}
return null;
}
/**
* Memproses RADIUS Access-Request (Authentication)
*/
function handleAuthMessage(msg, rinfo) {
const nasIp = rinfo.address;
const secret = getNasSecret(nasIp);
let reqPacket;
try {
reqPacket = decodePacket(msg, secret);
} catch (err) {
logger.warn(`[RADIUS Auth] Gagal decode paket dari ${nasIp}:${rinfo.port} - ${err.message}`);
return;
}
if (reqPacket.code !== CODES.ACCESS_REQUEST) {
return;
}
const username = reqPacket.parsedAttrs.username || '';
const inputPassword = reqPacket.parsedAttrs.password || '';
logger.info(`[RADIUS Auth] Request dari NAS ${nasIp} untuk user '${username}'`);
const user = findUserCredentials(username);
if (!user) {
logger.warn(`[RADIUS Auth] Reject '${username}' - User tidak ditemukan`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
}
// Verifikasi Password (Mendukung PAP dan CHAP RFC 2865)
if (reqPacket.parsedAttrs.chapPassword) {
const challenge = reqPacket.parsedAttrs.chapChallenge || reqPacket.authenticator;
const isChapValid = verifyChapPassword(reqPacket.parsedAttrs.chapPassword, user.secret, challenge);
if (!isChapValid) {
logger.warn(`[RADIUS Auth] Reject '${username}' - Password CHAP salah`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
}
} else if (reqPacket.parsedAttrs.password !== undefined) {
if (reqPacket.parsedAttrs.password !== user.secret) {
logger.warn(`[RADIUS Auth] Reject '${username}' - Password PAP salah (input: '${reqPacket.parsedAttrs.password}', expected: '${user.secret}')`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
}
} else {
logger.warn(`[RADIUS Auth] Reject '${username}' - Tidak ada atribut Password (PAP/CHAP) pada request`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
}
const isolirAction = getSetting('radius_isolir_action', 'pool');
const isolirPool = getSetting('radius_isolir_pool', 'isolir');
// Penanganan Status Terisolir / Non-Aktif
if (user.status === 'suspended' || user.status === 'isolir' || user.status === 'inactive') {
if (isolirAction === 'reject') {
logger.warn(`[RADIUS Auth] Reject '${username}' - Status terisolir`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
} else {
const isolirAttrs = [
{ type: ATTR_TYPES.SERVICE_TYPE, value: 2 },
{ type: ATTR_TYPES.FRAMED_PROTOCOL, value: 1 }
];
const isolirRateLimit = getSetting('radius_isolir_rate_limit', '512k/512k');
const isolirIpPoolEnabled = getSetting('radius_isolir_ip_pool_enabled', '1') === '1';
const isolirIpPoolStart = getSetting('radius_isolir_ip_pool_start', '10.10.99.2');
const isolirIpPoolEnd = getSetting('radius_isolir_ip_pool_end', '10.10.99.254');
let allocatedIsolirIp = null;
if (isolirIpPoolEnabled && isolirIpPoolStart && isolirIpPoolEnd) {
allocatedIsolirIp = allocateDynamicIp(username, isolirIpPoolStart, isolirIpPoolEnd, nasIp);
if (allocatedIsolirIp) {
isolirAttrs.push({ type: ATTR_TYPES.FRAMED_IP_ADDRESS, value: allocatedIsolirIp, isIp: true });
isolirAttrs.push({ type: ATTR_TYPES.FRAMED_IP_NETMASK, value: '255.255.255.255', isIp: true });
logger.info(`[RADIUS Auth] Isolir Dynamic IP '${allocatedIsolirIp}' dialokasikan untuk '${username}'`);
} else if (isolirPool) {
isolirAttrs.push({ type: ATTR_TYPES.FRAMED_POOL, value: isolirPool });
}
} else if (isolirPool) {
isolirAttrs.push({ type: ATTR_TYPES.FRAMED_POOL, value: isolirPool });
}
if (isolirRateLimit) {
isolirAttrs.push({
type: ATTR_TYPES.VENDOR_SPECIFIC,
vendorId: MIKROTIK_VENDOR_ID,
vendorType: MIKROTIK_VSAS.RATE_LIMIT,
value: isolirRateLimit
});
}
logger.info(`[RADIUS Auth] Accept (Isolir) '${username}' - Terisolir`);
sendAuthResponse(CODES.ACCESS_ACCEPT, reqPacket, isolirAttrs, secret, rinfo);
return;
}
}
// Hapus dummy auth-* session untuk user ini agar re-dial / re-connect tidak terblokir
try {
db.prepare(`
DELETE FROM radius_accounting
WHERE username = ? AND session_id LIKE 'auth-%'
`).run(username);
} catch (e) {}
// 3. Cek Batasan Sesi Login Ganda (Simultaneous-Use / Multi-Login)
const limitSimultaneous = getSetting('radius_limit_simultaneous', '1') === '1';
if (limitSimultaneous) {
const activeSession = db.prepare(`
SELECT COUNT(1) as c FROM radius_accounting
WHERE username = ? AND status_type IN (1, 3)
AND session_id NOT LIKE 'auth-%'
AND updated_at >= datetime('now', '-15 minutes')
`).get(username)?.c || 0;
if (activeSession >= 1) {
logger.warn(`[RADIUS Auth] Reject '${username}' - Sesi aktif ganda terdeteksi (User sudah online)`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
}
}
// Status Aktif -> Access-Accept dengan Atribut Kuota/Speed Limit
const responseAttrs = [
{ type: ATTR_TYPES.SERVICE_TYPE, value: 2 },
{ type: ATTR_TYPES.FRAMED_PROTOCOL, value: 1 }
];
// Mode profil MikroTik: RADIUS tidak kirim atribut IP/Rate-Limit (diambil dari profil PPP di router)
const useProfile = getSetting('radius_use_profile', '0') === '1';
// Dynamic IP Pool & Static IP Allocation
const ipPoolEnabled = getSetting('radius_ip_pool_enabled', '1') === '1';
const ipPoolStart = getSetting('radius_ip_pool_start', '10.10.10.2');
const ipPoolEnd = getSetting('radius_ip_pool_end', '10.10.10.254');
const framedPool = getSetting('radius_framed_pool', 'pool-pppoe');
let allocatedIp = null;
if (!useProfile && user.staticIp) {
responseAttrs.push({ type: ATTR_TYPES.FRAMED_IP_ADDRESS, value: user.staticIp, isIp: true });
responseAttrs.push({ type: ATTR_TYPES.FRAMED_IP_NETMASK, value: '255.255.255.255', isIp: true });
} else if (!useProfile && ipPoolEnabled && ipPoolStart && ipPoolEnd) {
allocatedIp = allocateDynamicIp(username, ipPoolStart, ipPoolEnd, nasIp);
if (allocatedIp) {
responseAttrs.push({ type: ATTR_TYPES.FRAMED_IP_ADDRESS, value: allocatedIp, isIp: true });
responseAttrs.push({ type: ATTR_TYPES.FRAMED_IP_NETMASK, value: '255.255.255.255', isIp: true });
logger.info(`[RADIUS Auth] Dynamic IP '${allocatedIp}' dialokasikan untuk '${username}'`);
} else if (framedPool) {
responseAttrs.push({ type: ATTR_TYPES.FRAMED_POOL, value: framedPool });
}
} else if (!useProfile && user.poolName && String(user.poolName).trim()) {
responseAttrs.push({ type: ATTR_TYPES.FRAMED_POOL, value: String(user.poolName).trim() });
logger.info("[RADIUS Auth] Framed-Pool '" + String(user.poolName).trim() + "' (dari paket) untuk '" + username + "'");
} else if (!useProfile && framedPool) {
responseAttrs.push({ type: ATTR_TYPES.FRAMED_POOL, value: framedPool });
}
// Atribut Rate Limit (Mikrotik-Rate-Limit) & Mikrotik-Group (Profile)
const defaultRateLimit = getSetting('radius_default_rate_limit', '5M/10M');
const rateLimitStr = formatRateLimit(user.speedUp, user.speedDown, defaultRateLimit, user.speedUpUpto || 0, user.speedDownUpto || 0);
if (!useProfile && rateLimitStr) {
responseAttrs.push({
type: ATTR_TYPES.VENDOR_SPECIFIC,
vendorId: MIKROTIK_VENDOR_ID,
vendorType: MIKROTIK_VSAS.RATE_LIMIT,
value: rateLimitStr
});
logger.info(`[RADIUS Auth] Rate-limit '${rateLimitStr}' dikirim untuk '${username}'`);
}
// Kirimkan nama paket / profile ke MikroTik via Mikrotik-Group hanya jika diset & valid
const sendGroup = getSetting('radius_send_group', '0') === '1';
if ((useProfile || sendGroup) && user.packageName) {
responseAttrs.push({
type: ATTR_TYPES.VENDOR_SPECIFIC,
vendorId: MIKROTIK_VENDOR_ID,
vendorType: MIKROTIK_VSAS.GROUP,
value: String(user.packageName).trim()
});
}
logger.info(`[RADIUS Auth] Accept '${username}' - Berhasil diautentikasi`);
// Record instant online session entry upon Access-Accept
try {
const authSessionId = reqPacket.parsedAttrs.acctSessionId || `auth-${Date.now()}-${username}`;
db.prepare(`
INSERT INTO radius_accounting (
username, nas_ip, framed_ip, session_id, status_type,
calling_station_id, updated_at
) VALUES (?, ?, ?, ?, 1, ?, NOW_LOCAL())
`).run(
username,
nasIp,
user.staticIp || allocatedIp || '',
authSessionId,
reqPacket.parsedAttrs.callingStationId || ''
);
} catch (e) {}
sendAuthResponse(CODES.ACCESS_ACCEPT, reqPacket, responseAttrs, secret, rinfo);
}
function sendAuthResponse(code, reqPacket, attributes, secret, rinfo) {
try {
const resBuf = encodeResponsePacket({
code,
identifier: reqPacket.identifier,
requestAuthenticator: reqPacket.authenticator,
attributes,
secret
});
authSocket.send(resBuf, rinfo.port, rinfo.address);
} catch (err) {
logger.error(`[RADIUS Auth] Gagal me-reply response: ${err.message}`);
}
}
/**
* Memproses RADIUS Accounting-Request
*/
function handleAcctMessage(msg, rinfo) {
const nasIp = rinfo.address;
const secret = getNasSecret(nasIp);
let reqPacket;
try {
reqPacket = decodePacket(msg, secret);
} catch (err) {
logger.warn(`[RADIUS Acct] Gagal decode paket dari ${nasIp}:${rinfo.port} - ${err.message}`);
return;
}
if (reqPacket.code !== CODES.ACCOUNTING_REQUEST) {
return;
}
const {
username = '',
acctStatusType = 1,
acctSessionId = '',
framedIp = '',
acctInputOctets = 0,
acctOutputOctets = 0,
acctInputGigawords = 0,
acctOutputGigawords = 0,
acctSessionTime = 0,
acctTerminateCause = 0,
callingStationId = '',
calledStationId = ''
} = reqPacket.parsedAttrs;
if (username && acctSessionId) {
try {
let effectiveFramedIp = framedIp;
if (!effectiveFramedIp || effectiveFramedIp === '0.0.0.0') {
const recentAuth = db.prepare(`
SELECT framed_ip FROM radius_accounting
WHERE username = ? AND framed_ip IS NOT NULL AND framed_ip != '' AND framed_ip != '0.0.0.0'
ORDER BY id DESC LIMIT 1
`).get(username);
if (recentAuth && recentAuth.framed_ip) {
effectiveFramedIp = recentAuth.framed_ip;
} else {
const cust = db.prepare(`SELECT static_ip FROM customers WHERE pppoe_username = ? OR name = ? LIMIT 1`).get(username, username);
if (cust && cust.static_ip) {
effectiveFramedIp = cust.static_ip;
}
}
}
// Upsert ke radius_accounting
const stmt = db.prepare(`
INSERT INTO radius_accounting (
username, nas_ip, framed_ip, session_id, status_type,
input_octets, output_octets, input_gigawords, output_gigawords,
session_time, terminate_cause, calling_station_id, called_station_id,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW_LOCAL())
`);
// Cek apakah session_id sudah ada
const existing = db.prepare(`SELECT id FROM radius_accounting WHERE session_id = ? LIMIT 1`).get(acctSessionId);
if (existing) {
db.prepare(`
UPDATE radius_accounting SET
status_type = ?,
framed_ip = COALESCE(NULLIF(?, ''), framed_ip),
input_octets = ?,
output_octets = ?,
input_gigawords = ?,
output_gigawords = ?,
session_time = ?,
terminate_cause = ?,
updated_at = NOW_LOCAL()
WHERE session_id = ?
`).run(
acctStatusType,
effectiveFramedIp,
acctInputOctets,
acctOutputOctets,
acctInputGigawords,
acctOutputGigawords,
acctSessionTime,
acctTerminateCause,
acctSessionId
);
} else {
// Hapus entri auth-* lama untuk user ini ketika sesi accounting asli dimulai
try {
db.prepare(`DELETE FROM radius_accounting WHERE username = ? AND session_id LIKE 'auth-%'`).run(username);
db.prepare(`
UPDATE radius_accounting
SET status_type = 2, terminate_cause = 1, updated_at = NOW_LOCAL()
WHERE username = ? AND session_id != ? AND status_type IN (1, 3)
`).run(username, acctSessionId);
} catch (e) {}
stmt.run(
username, nasIp, effectiveFramedIp, acctSessionId, acctStatusType,
acctInputOctets, acctOutputOctets, acctInputGigawords, acctOutputGigawords,
acctSessionTime, acctTerminateCause, callingStationId, calledStationId
);
}
// Hitung total bytes termasuk Gigawords (untuk sesi > 4GB)
const totalBytesIn = (acctInputGigawords * 4294967296) + acctInputOctets;
const totalBytesOut = (acctOutputGigawords * 4294967296) + acctOutputOctets;
// Catat sampel trafik ke pppoe_traffic_samples jika tabel pppoe_users ada
try {
const pppoeUser = db.prepare(`SELECT id FROM pppoe_users WHERE username = ? LIMIT 1`).get(username);
if (pppoeUser) {
db.prepare(`
INSERT INTO pppoe_traffic_samples (pppoe_user_id, bytes_in, bytes_out)
VALUES (?, ?, ?)
`).run(pppoeUser.id, totalBytesIn, totalBytesOut);
}
} catch (e) {}
// Catat sampel pemakaian ke customer_usage jika terdaftar di customers
try {
const cust = db.prepare(`SELECT id FROM customers WHERE pppoe_username = ? OR name = ? LIMIT 1`).get(username, username);
if (cust) {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
db.prepare(`
INSERT INTO customer_usage (customer_id, period_month, period_year, bytes_in, bytes_out, updated_at)
VALUES (?, ?, ?, ?, ?, NOW_LOCAL())
ON CONFLICT(customer_id, period_month, period_year) DO UPDATE SET
bytes_in = MAX(customer_usage.bytes_in, excluded.bytes_in),
bytes_out = MAX(customer_usage.bytes_out, excluded.bytes_out),
updated_at = NOW_LOCAL()
`).run(cust.id, month, year, totalBytesIn, totalBytesOut);
}
} catch (e) {}
} catch (err) {
logger.error(`[RADIUS Acct] Gagal simpan accounting: ${err.message}`);
}
}
// Kirim Accounting-Response
try {
const resBuf = encodeResponsePacket({
code: CODES.ACCOUNTING_RESPONSE,
identifier: reqPacket.identifier,
requestAuthenticator: reqPacket.authenticator,
attributes: [],
secret
});
acctSocket.send(resBuf, rinfo.port, rinfo.address);
} catch (err) {
logger.error(`[RADIUS Acct] Gagal me-reply Accounting-Response: ${err.message}`);
}
}
/**
* Menjalankan Server RADIUS (UDP Auth & Acct)
*/
function start() {
if (isRunning) return;
const authPort = parseInt(getSetting('radius_auth_port', 1812), 10) || 1812;
const acctPort = parseInt(getSetting('radius_acct_port', 1813), 10) || 1813;
try {
authSocket = dgram.createSocket('udp4');
authSocket.on('message', handleAuthMessage);
authSocket.on('error', (err) => logger.error(`[RADIUS Auth Error] ${err.message}`));
authSocket.bind(authPort, () => {
logger.info(`[RADIUS] Auth Server mendengarkan pada port UDP ${authPort}`);
});
acctSocket = dgram.createSocket('udp4');
acctSocket.on('message', handleAcctMessage);
acctSocket.on('error', (err) => logger.error(`[RADIUS Acct Error] ${err.message}`));
acctSocket.bind(acctPort, () => {
logger.info(`[RADIUS] Accounting Server mendengarkan pada port UDP ${acctPort}`);
});
isRunning = true;
} catch (err) {
logger.error(`[RADIUS] Gagal menjalankan server RADIUS: ${err.message}`);
}
}
/**
* Menghentikan Server RADIUS
*/
function stop() {
if (!isRunning) return;
try {
if (authSocket) authSocket.close();
if (acctSocket) acctSocket.close();
} catch (e) {}
authSocket = null;
acctSocket = null;
isRunning = false;
logger.info(`[RADIUS] Server RADIUS telah dihentikan.`);
}
/**
* Format Mikrotik-Rate-Limit string dari nilai kecepatan paket.
*
* PENTING: Format MikroTik Rate-Limit menggunakan perspektif CLIENT:
* - RX (Receive) = Download = Data yang DITERIMA client dari internet
* - TX (Transmit) = Upload = Data yang DIKIRIM client ke internet
*
* Format standar: "rx-rate/tx-rate" atau lebih lengkap dengan burst:
* "rx-rate/tx-rate rx-burst/tx-burst rx-threshold/tx-threshold burst-time"
*
* Contoh: "10M/5M" = Download 10Mbps / Upload 5Mbps
* Contoh burst: "10M/5M 20M/10M 10M/5M 8" = CIR 10M/5M, Burst 20M/10M, Threshold 10M/5M, Time 8s
*
* @param {number} upVal - Kecepatan UPLOAD dalam Kbps (TX)
* @param {number} downVal - Kecepatan DOWNLOAD dalam Kbps (RX)
* @param {string} defaultVal - Nilai default jika speed tidak valid
* @param {number} uptoUp - Burst UPLOAD dalam Kbps (TX-burst)
* @param {number} uptoDown - Burst DOWNLOAD dalam Kbps (RX-burst)
* @returns {string} Format MikroTik Rate-Limit: "rx-rate/tx-rate" atau dengan burst
*/
function formatRateLimit(upVal, downVal, defaultVal = '5M/10M', uptoUp = 0, uptoDown = 0) {
function parseSpeed(v) {
if (!v) return 0;
const str = String(v).trim().toLowerCase();
// Jika sudah dalam Kbps mentah (angka tanpa satuan — dari DB yang menyimpan dalam Kbps)
if (str.endsWith('m')) return Math.round(parseFloat(str) * 1000);
if (str.endsWith('k')) return Math.round(parseFloat(str));
const num = parseFloat(str) || 0;
return num;
}
function kbpsToStr(kbps) {
if (kbps <= 0) return null;
if (kbps >= 1000 && kbps % 1000 === 0) return `${kbps / 1000}M`;
return `${kbps}k`;
}
const upKbps = parseSpeed(upVal); // TX (Upload)
const downKbps = parseSpeed(downVal); // RX (Download)
if (upKbps <= 0 || downKbps <= 0) {
return defaultVal;
}
// MikroTik format: RX/TX (Download/Upload)
const rxStr = kbpsToStr(upKbps); // RX = upload klien
const txStr = kbpsToStr(downKbps); // TX = download klien
// Tambahkan burst rate jika tersedia (Mikrotik-Rate-Limit format: rx/tx rx-burst/tx-burst rx-threshold/tx-threshold burst-time)
const uptoUpKbps = parseSpeed(uptoUp); // TX-burst
const uptoDownKbps = parseSpeed(uptoDown); // RX-burst
if (uptoUpKbps > 0 && uptoDownKbps > 0 && (uptoUpKbps > upKbps || uptoDownKbps > downKbps)) {
const rxBurstStr = kbpsToStr(uptoUpKbps); // RX-burst = upload burst
const txBurstStr = kbpsToStr(uptoDownKbps); // TX-burst = download burst
// Format MikroTik: "rx/tx rx-burst/tx-burst rx-threshold/tx-threshold burst-time"
// Threshold default 50% dari burst, waktu burst 8 detik
const rxThresholdStr = rxStr;
const txThresholdStr = txStr;
return `${rxStr}/${txStr} ${rxBurstStr}/${txBurstStr} ${rxThresholdStr}/${txThresholdStr} 8/8`;
}
// Format standar: rx/tx (Download/Upload)
return `${rxStr}/${txStr}`;
}
function ipToInt(ip) {
if (!ip) return 0;
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
}
function intToIp(int) {
return [
(int >>> 24) & 255,
(int >>> 16) & 255,
(int >>> 8) & 255,
int & 255
].join('.');
}
function allocateDynamicIp(username, startIpStr, endIpStr, nasIp) {
try {
const startInt = ipToInt(startIpStr);
const endInt = ipToInt(endIpStr);
if (!startInt || !endInt || startInt > endInt) return null;
// Direct check if user has active session with framed_ip
const userSession = db.prepare(`
SELECT framed_ip FROM radius_accounting
WHERE username = ? AND status_type IN (1, 3) AND framed_ip IS NOT NULL AND framed_ip != ''
ORDER BY id DESC LIMIT 1
`).get(username);
if (userSession && userSession.framed_ip) {
const sessInt = ipToInt(userSession.framed_ip);
if (sessInt >= startInt && sessInt <= endInt) {
return userSession.framed_ip;
}
}
const assignedRows = db.prepare(`
SELECT framed_ip FROM radius_accounting
WHERE status_type IN (1, 3) AND framed_ip IS NOT NULL AND framed_ip != ''
`).all();
const usedIps = new Set(assignedRows.map(r => r.framed_ip));
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = (hash * 31 + username.charCodeAt(i)) >>> 0;
}
const range = (endInt - startInt + 1);
const preferredInt = startInt + (hash % range);
const preferredIp = intToIp(preferredInt);
if (!usedIps.has(preferredIp)) {
return preferredIp;
}
for (let current = startInt; current <= endInt; current++) {
const candidateIp = intToIp(current);
if (!usedIps.has(candidateIp)) {
return candidateIp;
}
}
} catch (err) {
logger.error(`[RADIUS Dynamic IP] Error allocating IP: ${err.message}`);
}
return null;
}
function getStatus() {
return {
enabled: getSetting('radius_enabled', '0') === '1',
running: isRunning,
authPort: parseInt(getSetting('radius_auth_port', 1812), 10) || 1812,
acctPort: parseInt(getSetting('radius_acct_port', 1813), 10) || 1813,
secret: getSetting('radius_secret', 'secret123'),
isolirAction: getSetting('radius_isolir_action', 'pool'),
isolirPool: getSetting('radius_isolir_pool', 'isolir'),
isolirRateLimit: getSetting('radius_isolir_rate_limit', '512k/512k'),
isolirIpPoolEnabled: getSetting('radius_isolir_ip_pool_enabled', '1') === '1',
isolirIpPoolStart: getSetting('radius_isolir_ip_pool_start', '10.10.99.2'),
isolirIpPoolEnd: getSetting('radius_isolir_ip_pool_end', '10.10.99.254'),
limitSimultaneous: getSetting('radius_limit_simultaneous', '1') === '1',
defaultRateLimit: getSetting('radius_default_rate_limit', '5M/10M'),
ipPoolEnabled: getSetting('radius_ip_pool_enabled', '1') === '1',
ipPoolStart: getSetting('radius_ip_pool_start', '10.10.10.2'),
ipPoolEnd: getSetting('radius_ip_pool_end', '10.10.10.254'),
framedPool: getSetting('radius_framed_pool', 'pool-pppoe')
};
}
function getOnlineSessions() {
try {
return db.prepare(`
SELECT ra.*, c.name as customer_name
FROM radius_accounting ra
LEFT JOIN customers c ON (c.pppoe_username = ra.username OR c.name = ra.username)
WHERE ra.status_type IN (1, 3)
AND ra.updated_at >= datetime('now', '-24 hours')
ORDER BY ra.updated_at DESC LIMIT 200
`).all();
} catch (e) {
return [];
}
}
function getAccountingLogs(limit = 100) {
try {
return db.prepare(`
SELECT * FROM radius_accounting
ORDER BY id DESC LIMIT ?
`).all(limit);
} catch (e) {
return [];
}
}
async function disconnectSession(username, sessionId, nasIp) {
try {
const mikrotikSvc = require('./mikrotikService');
// 1. Clear session entry from radius_accounting database table
if (sessionId) {
db.prepare(`
UPDATE radius_accounting
SET status_type = 2, updated_at = NOW_LOCAL()
WHERE session_id = ? OR (username = ? AND status_type IN (1, 3))
`).run(sessionId, username);
} else {
db.prepare(`
UPDATE radius_accounting
SET status_type = 2, updated_at = NOW_LOCAL()
WHERE username = ? AND status_type IN (1, 3)
`).run(username);
}
// 2. Disconnect active session from MikroTik via API
let routerId = null;
if (nasIp) {
const r = db.prepare('SELECT id FROM routers WHERE host = ?').get(nasIp);
if (r) routerId = r.id;
}
await mikrotikSvc.kickPppoeUser(username, routerId);
return true;
} catch (err) {
logger.error(`[RADIUS Disconnect] Error disconnecting user '${username}': ${err.message}`);
return false;
}
}
module.exports = {
start,
stop,
getStatus,
getOnlineSessions,
getAccountingLogs,
disconnectSession
};
+72
View File
@@ -0,0 +1,72 @@
'use strict';
/**
* registrationService.js — Perhitungan biaya awal pendaftaran (prorata, acuan tgl 5).
* biaya pasang + prorata paket − diskon voucher = total bayar awal.
*
* Aturan acuan tgl 5:
* - daftar SEBELUM tgl 5 → paket dihitung dari tgl daftar sampai tgl 5 BULAN INI
* - daftar tgl 5 atau SESUDAH → dihitung sampai tgl 5 BULAN DEPAN
*/
const { getSetting } = require('../config/settingsManager');
const voucherService = require('./voucherService');
function localParts(date) {
const tz = getSetting('timezone', 'Asia/Jakarta');
try {
const fmt = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' });
const o = {};
fmt.formatToParts(date).forEach(p => { o[p.type] = p.value; });
return { y: Number(o.year), m: Number(o.month), d: Number(o.day) };
} catch (e) {
return { y: date.getFullYear(), m: date.getMonth() + 1, d: date.getDate() };
}
}
function pad(n) { return String(n).padStart(2, '0'); }
function computeBill(pkgPrice, opts = {}) {
const now = opts.now ? new Date(opts.now) : new Date();
const { y, m, d } = localParts(now);
const price = Math.max(0, Math.round(Number(pkgPrice) || 0));
const installFee = Math.max(0, Math.round(Number(opts.installFee != null ? opts.installFee : getSetting('registration_install_fee', 0)) || 0));
let dueY = y, dueM = m;
if (d >= 5) {
if (m === 12) { dueY = y + 1; dueM = 1; } else { dueM = m + 1; }
}
const start = new Date(y, m - 1, d);
const due = new Date(dueY, dueM - 1, 5);
const diffDays = Math.max(1, Math.round((due.getTime() - start.getTime()) / 86400000));
const daysInMonth = new Date(dueY, dueM, 0).getDate();
const costPerDay = price / daysInMonth;
const prorata = Math.round(diffDays * costPerDay);
const gross = prorata + installFee;
let discount = 0;
let voucher = null;
if (opts.voucherCode) {
const v = voucherService.getByCode(opts.voucherCode);
if (v) {
const chk = voucherService.validate(opts.voucherCode, gross);
if (chk.ok) { discount = chk.discount; voucher = v; }
}
}
const total = Math.max(0, gross - discount);
return {
price,
installFee,
startDate: `${y}-${pad(m)}-${pad(d)}`,
dueDate: `${dueY}-${pad(dueM)}-05`,
diffDays,
daysInMonth,
costPerDay: Math.round(costPerDay),
prorata,
gross,
discount,
total,
voucherCode: voucher ? voucher.code : '',
voucherValid: !!voucher
};
}
module.exports = { computeBill, localParts };
+120
View File
@@ -0,0 +1,120 @@
'use strict';
/**
* serviceNoticeService.js
* Pengumuman status layanan (NORMAL / GANGGUAN MASAL / PERBAIKAN SISTEM).
*
* Mode diatur di panel admin (settings.json):
* notice_mode : 'NORMAL' | 'GANGGUAN_MASAL' | 'PERBAIKAN'
* notice_start : jam mulai gangguan/perbaikan (ISO lokal, mis. 2026-10-05T07:00)
* notice_interval_hours : interval estimasi (jam), default 5
* notice_note : catatan kustom (opsional)
*
* Estimasi selesai dihitung dinamis dan MAJU OTOMATIS tiap interval
* selama admin belum mengubah mode kembali ke NORMAL:
* estimate = start + (floor((now - start) / interval) + 1) * interval
* Contoh: start 07:00, interval 5 jam -> 12:00, 17:00, 22:00, ...
*/
const { getSetting } = require('../config/settingsManager');
const MODE_NORMAL = 'NORMAL';
const MODE_GANGGUAN = 'GANGGUAN_MASAL';
const MODE_PERBAIKAN = 'PERBAIKAN';
let _cache = { at: 0, value: null };
function getTimezone() {
return getSetting('timezone', 'Asia/Jakarta') || 'Asia/Jakarta';
}
function fmtDateTime(d) {
if (!d) return '-';
try {
const date = d instanceof Date ? d : new Date(d);
if (!date || isNaN(date.getTime())) return '-';
const timeZone = getTimezone();
const tgl = date.toLocaleDateString('id-ID', { timeZone, day: '2-digit', month: '2-digit', year: 'numeric' });
const jam = date.toLocaleTimeString('id-ID', { timeZone, hour: '2-digit', minute: '2-digit' });
return `${jam} ${tgl}`;
} catch (e) {
return '-';
}
}
function getIntervalHours() {
let n = parseInt(getSetting('notice_interval_hours', 5), 10);
if (!Number.isFinite(n) || n < 1) n = 5;
if (n > 168) n = 168; // maksimal 1 minggu
return n;
}
function normalizeMode(raw) {
const mode = String(raw || '').trim().toUpperCase();
if (mode === MODE_GANGGUAN || mode === MODE_PERBAIKAN) return mode;
return MODE_NORMAL;
}
function computeNotice(nowInput) {
const now = nowInput ? new Date(nowInput) : new Date();
const mode = normalizeMode(getSetting('notice_mode', MODE_NORMAL));
if (mode === MODE_NORMAL) {
return { mode: MODE_NORMAL, active: false, tone: 'normal', label: 'Normal', badgeLabel: 'Normal' };
}
const isMass = mode === MODE_GANGGUAN;
const label = isMass ? 'Gangguan Masal' : 'Perbaikan Sistem';
const defaultText = isMass
? 'Sedang terjadi gangguan pada jaringan kami. Tim teknis sedang menangani dan akan segera memulihkan layanan.'
: 'Sedang ada sedikit perbaikan sistem. Mohon kesabarannya ya, layanan akan kembali normal secepatnya.';
const note = String(getSetting('notice_note', '') || '').trim();
const text = note || defaultText;
const intervalHours = getIntervalHours();
const startStr = String(getSetting('notice_start', '') || '').trim();
let start = startStr ? new Date(startStr) : null;
if (start && isNaN(start.getTime())) start = null;
let estimate = null;
if (start) {
const ms = intervalHours * 3600 * 1000;
const elapsed = now.getTime() - start.getTime();
const steps = Math.max(1, Math.floor(elapsed / ms) + 1);
estimate = new Date(start.getTime() + steps * ms);
}
return {
mode,
active: true,
tone: isMass ? 'danger' : 'primary',
label,
badgeLabel: isMass ? 'Gamas' : 'Perbaikan Sistem',
text,
intervalHours,
start,
startText: start ? fmtDateTime(start) : null,
estimate,
estimateText: estimate ? fmtDateTime(estimate) : null
};
}
function getServiceNotice() {
const t = Date.now();
if (_cache.value && (t - _cache.at) < 5000) return _cache.value;
let value;
try {
value = computeNotice();
} catch (e) {
value = { mode: MODE_NORMAL, active: false, tone: 'normal', label: 'Normal', badgeLabel: 'Normal' };
}
_cache = { at: t, value };
return value;
}
module.exports = {
computeNotice,
getServiceNotice,
fmtDateTime,
MODE_NORMAL,
MODE_GANGGUAN,
MODE_PERBAIKAN
};
+375
View File
@@ -0,0 +1,375 @@
const crypto = require('crypto');
const { getSetting, getSettings, saveSettings } = require('../config/settingsManager');
const { getAppSetting, saveAppSetting } = require('../config/database');
const FEATURE_PASSWORD_HASH = 'af03458614be223016c5bc1c0788eaefc6a6bfd0c11cdcd4bc520f49d0cb474a'; // sha256 kode aktivasi — plaintext hanya di settings.json (activation_code), tidak di repo
const FEATURE_CONTACT_PHONE = '081947215703';
function getFeaturePasswordHash() {
return getSetting('feature_password_hash', FEATURE_PASSWORD_HASH);
}
function getFeatureContactPhone() {
return getSetting('feature_contact_phone', FEATURE_CONTACT_PHONE);
}
const SETTINGS_KEY = 'sidebar_menu_states';
const STATE_VISIBLE = 'visible';
const STATE_HIDDEN = 'hidden';
const STATE_LOCKED = 'locked';
const VALID_STATES = new Set([STATE_VISIBLE, STATE_HIDDEN, STATE_LOCKED]);
const MENU_DEFINITIONS = [
{ key: 'dashboard', section: 'main', href: '/admin', icon: 'bi bi-speedometer2', labelKey: 'admin.nav.dashboard', labelDefault: 'Dashboard', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 1, activePages: ['dashboard'] },
{ key: 'network', section: 'main', href: '/admin/mikrotik', icon: 'bi bi-diagram-3', labelKey: 'admin.nav.network', labelDefault: 'Jaringan', roles: ['admin', 'cashier'], activePages: ['mikrotik', 'radius_settings', 'acs_pro', 'onu_provision', 'olts', 'monitoring'], children: [
{ key: 'mikrotik', section: 'main', href: '/admin/mikrotik', icon: 'bi bi-router', labelKey: 'admin.nav.mikrotik', labelDefault: 'MikroTik', roles: ['admin', 'cashier'], activePages: ['mikrotik'] },
{ key: 'radius_server', section: 'main', href: '/admin/radius-settings', icon: 'bi bi-broadcast', labelKey: 'admin.nav.radius_server', labelDefault: 'RADIUS Server', roles: ['admin'], activePages: ['radius_settings'] },
{ key: 'acs_pro', section: 'main', href: '/admin/acs', icon: 'bi bi-hdd-network', labelKey: 'admin.nav.acs_pro', labelDefault: 'GenieACS Pro', roles: ['admin'], activePages: ['acs_pro'] },
{ key: 'onu_provision', section: 'main', href: '/admin/onu-provision', icon: 'bi bi-hdd-network-fill', labelKey: 'admin.nav.onu_provision', labelDefault: 'ONU Provision', roles: ['admin'], activePages: ['onu_provision'] },
{ key: 'olts', section: 'main', href: '/admin/olts', icon: 'bi bi-hdd-fill', labelKey: 'admin.nav.olt_management', labelDefault: 'Manajemen OLT', roles: ['admin'], activePages: ['olts'] },
{ key: 'monitoring', section: 'main', href: '/admin/monitoring', icon: 'bi bi-activity', labelKey: 'admin.nav.monitoring', labelDefault: 'Monitoring Sistem', roles: ['admin'], activePages: ['monitoring'] }
] },
{ key: 'map', section: 'main', href: '/admin/map', icon: 'bi bi-map', labelKey: 'admin.nav.network_map', labelDefault: 'Peta Jaringan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 4, activePages: ['map'] },
{ key: 'whatsapp', section: 'main', href: '/admin/whatsapp', icon: 'bi bi-whatsapp', labelKey: 'admin.nav.whatsapp', labelDefault: 'WhatsApp', roles: ['admin', 'cashier'], activePages: ['whatsapp','broadcast','whatsapp_live_chat','whatsapp_templates'] },
{ key: 'automation', section: 'main', href: '/admin/automation', icon: 'bi bi-robot', labelKey: 'admin.nav.automation', labelDefault: 'Automation', roles: ['admin'], activePages: ['automation'] },
{ key: 'grp_customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers', labelDefault: 'Pelanggan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 2, activePages: ['customers', 'onu_stickers'], children: [
{ key: 'customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers_data', labelDefault: 'Data Pelanggan', roles: ['admin', 'cashier'], activePages: ['customers'] },
{ key: 'onu_stickers', section: 'billing', href: '/admin/onu-stickers', icon: 'bi bi-qr-code', labelKey: 'admin.nav.onu_stickers', labelDefault: 'Stiker Modem ONU', roles: ['admin', 'cashier'], activePages: ['onu_stickers'] }
] },
{ key: 'grp_packages', section: 'billing', href: '/admin/packages', icon: 'bi bi-box-seam', labelKey: 'admin.nav.grp_packages', labelDefault: 'Paket & Voucher', roles: ['admin', 'cashier'], activePages: ['packages', 'voucher_packages', 'vouchers_discounts', 'discounts', 'promo_slides'], children: [
{ key: 'packages', section: 'billing', href: '/admin/packages', icon: 'bi bi-box-seam', labelKey: 'admin.nav.internet_packages', labelDefault: 'Paket Internet', roles: ['admin', 'cashier'], activePages: ['packages'] },
{ key: 'voucher_packages', section: 'billing', href: '/admin/vouchers/packages', icon: 'bi bi-ticket-detailed', labelKey: 'admin.nav.voucher_packages', labelDefault: 'Paket Voucher', roles: ['admin', 'cashier'], activePages: ['voucher_packages'] },
{ key: 'vouchers_discounts', section: 'billing', href: '/admin/vouchers-discounts', icon: 'bi bi-ticket-perforated', labelKey: 'admin.nav.vouchers_discounts', labelDefault: 'Biaya Pasang', roles: ['admin'], activePages: ['vouchers_discounts'] },
{ key: 'discounts', section: 'billing', href: '/admin/discounts', icon: 'bi bi-percent', labelKey: 'admin.nav.discounts', labelDefault: 'Diskon', roles: ['admin', 'cashier'], activePages: ['discounts'] },
{ key: 'promo_slides', section: 'billing', href: '/admin/promo-slides', icon: 'bi bi-image', labelKey: 'admin.nav.promo_slides', labelDefault: 'Promo Slides', roles: ['admin'], activePages: ['promo_slides'] }
] },
{ key: 'grp_billing', section: 'billing', href: '/admin/billing', icon: 'bi bi-receipt', labelKey: 'admin.nav.invoices', labelDefault: 'Tagihan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 3, activePages: ['billing', 'collector_payments'], children: [
{ key: 'billing', section: 'billing', href: '/admin/billing', icon: 'bi bi-receipt', labelKey: 'admin.nav.invoices_list', labelDefault: 'Data Tagihan', roles: ['admin', 'cashier'], activePages: ['billing'] },
{ key: 'collector_payments', section: 'billing', href: '/admin/collector-payments', icon: 'bi bi-check2-square', labelKey: 'admin.nav.collector_payments', labelDefault: 'Approval Kolektor', roles: ['admin', 'cashier'], activePages: ['collector_payments'] }
] },
{ key: 'grp_finance', section: 'finance', href: '/admin/reports', icon: 'bi bi-cash-stack', labelKey: 'admin.nav.grp_finance', labelDefault: 'Keuangan', roles: ['admin', 'cashier'], activePages: ['reports', 'cashiers_reports', 'payroll', 'cash_in', 'expenses', 'expense_categories'], children: [
{ key: 'reports', section: 'finance', href: '/admin/reports', icon: 'bi bi-bar-chart-line', labelKey: 'admin.nav.finance_report', labelDefault: 'Laporan Keuangan', roles: ['admin', 'cashier'], activePages: ['reports'] },
{ key: 'cashiers_reports', section: 'finance', href: '/admin/cashiers/reports', icon: 'bi bi-journal-text', labelKey: 'admin.nav.cashiers_reports', labelDefault: 'Laporan Kasir', roles: ['admin', 'cashier'], activePages: ['cashiers_reports'] },
{ key: 'cash_in', section: 'finance', href: '/admin/finance/cash-in', icon: 'bi bi-cash-stack', labelKey: 'admin.nav.cash_in', labelDefault: 'Kas Masuk', roles: ['admin', 'cashier'], activePages: ['cash_in'] },
{ key: 'expenses', section: 'finance', href: '/admin/finance/expenses', icon: 'bi bi-wallet2', labelKey: 'admin.nav.expenses', labelDefault: 'Pengeluaran', roles: ['admin', 'cashier'], activePages: ['expenses'] },
{ key: 'expense_categories', section: 'finance', href: '/admin/finance/expense-categories', icon: 'bi bi-tags', labelKey: 'admin.nav.expense_categories', labelDefault: 'Kategori Pengeluaran', roles: ['admin'], activePages: ['expense_categories'] },
{ key: 'payroll', section: 'finance', href: '/admin/payroll', icon: 'bi bi-wallet2', labelKey: 'admin.nav.payroll', labelDefault: 'Gaji & Payroll', roles: ['admin'], activePages: ['payroll'] }
] },
{ key: 'grp_service', section: 'service', href: '/admin/tickets', icon: 'bi bi-headset', labelKey: 'admin.nav.grp_service', labelDefault: 'Layanan', roles: ['admin', 'cashier'], activePages: ['tickets', 'inventory', 'attendance'], children: [
{ key: 'tickets', section: 'service', href: '/admin/tickets', icon: 'bi bi-headset', labelKey: 'admin.nav.customer_tickets', labelDefault: 'Keluhan Pelanggan', roles: ['admin', 'cashier'], activePages: ['tickets'] },
{ key: 'inventory', section: 'service', href: '/admin/inventory', icon: 'bi bi-boxes', labelKey: 'admin.nav.inventory', labelDefault: 'Inventaris (Stok)', roles: ['admin', 'cashier'], activePages: ['inventory'] },
{ key: 'attendance', section: 'service', href: '/admin/attendance', icon: 'bi bi-calendar-check', labelKey: 'admin.nav.attendance', labelDefault: 'Absensi Karyawan', roles: ['admin', 'cashier'], activePages: ['attendance'] }
] },
{ key: 'cashier_attendance', section: 'cashier', href: '/admin/cashiers/attendance', icon: 'bi bi-calendar-check', labelKey: 'admin.nav.cashier_attendance', labelDefault: 'Absensi Saya', roles: ['cashier'], activePages: ['cashier_attendance'] },
{ key: 'grp_users', section: 'user_management', href: '/admin/technicians', icon: 'bi bi-person-gear', labelKey: 'admin.nav.grp_users', labelDefault: 'Pengguna', roles: ['admin', 'cashier'], activePages: ['technicians', 'cashiers', 'collectors', 'areas'], children: [
{ key: 'technicians', section: 'user_management', href: '/admin/technicians', icon: 'bi bi-person-gear', labelKey: 'admin.nav.technicians', labelDefault: 'Teknisi', roles: ['admin'], activePages: ['technicians'] },
{ key: 'cashiers', section: 'user_management', href: '/admin/cashiers', icon: 'bi bi-person-vcard', labelKey: 'admin.nav.cashiers', labelDefault: 'Kasir', roles: ['admin'], activePages: ['cashiers'] },
{ key: 'collectors', section: 'user_management', href: '/admin/collectors', icon: 'bi bi-person-badge', labelKey: 'admin.nav.collectors', labelDefault: 'Kolektor', roles: ['admin'], activePages: ['collectors'] },
{ key: 'areas', section: 'user_management', href: '/admin/areas', icon: 'bi bi-geo-alt', labelKey: 'admin.nav.areas', labelDefault: 'Manajemen Area', roles: ['admin', 'cashier'], activePages: ['areas'] }
] },
{ key: 'grp_system', section: 'system', href: '/admin/settings', icon: 'bi bi-gear', labelKey: 'admin.nav.grp_system', labelDefault: 'Sistem', roles: ['admin'], activePages: ['update', 'service_notice', 'settings', 'ewallet_logs', 'backup', 'audit_logs'], children: [
{ key: 'update', section: 'system', href: '/admin/update', icon: 'bi bi-cloud-arrow-down', labelKey: 'admin.nav.update', labelDefault: 'Update GitHub', roles: ['admin'], activePages: ['update'] },
{ key: 'service_notice', section: 'system', href: '/admin/service-notice', icon: 'bi bi-megaphone-fill', labelKey: 'admin.nav.service_notice', labelDefault: 'Status Layanan', roles: ['admin'], activePages: ['service_notice'] },
{ key: 'settings', section: 'system', href: '/admin/settings', icon: 'bi bi-gear', labelKey: 'admin.nav.settings', labelDefault: 'Pengaturan', roles: ['admin'], activePages: ['settings'] },
{ key: 'ewallet_logs', section: 'system', href: '/admin/ewallet-logs', icon: 'bi bi-journal-text', labelKey: 'admin.nav.log_rekap', labelDefault: 'Log & Rekap', roles: ['admin'], activePages: ['ewallet_logs'] },
{ key: 'backup', section: 'system', href: '/admin/backup', icon: 'bi bi-hdd-stack', labelKey: 'admin.nav.backup', labelDefault: 'Backup & Recovery', roles: ['admin'], activePages: ['backup'] },
{ key: 'audit_logs', section: 'system', href: '/admin/audit-logs', icon: 'bi bi-shield-lock', labelKey: 'admin.nav.audit_logs', labelDefault: 'Log Aktivitas', roles: ['admin'], activePages: ['audit_logs'] }
] }
];
const DEFAULT_MENU_STATES = {
dashboard: STATE_VISIBLE,
mikrotik: STATE_VISIBLE,
network: STATE_VISIBLE,
grp_customers: STATE_VISIBLE,
grp_packages: STATE_VISIBLE,
grp_billing: STATE_VISIBLE,
grp_finance: STATE_VISIBLE,
grp_service: STATE_VISIBLE,
grp_users: STATE_VISIBLE,
grp_system: STATE_VISIBLE,
map: STATE_VISIBLE,
radius_server: STATE_VISIBLE,
map: STATE_VISIBLE,
acs_pro: STATE_VISIBLE,
onu_provision: STATE_VISIBLE,
olts: STATE_HIDDEN,
whatsapp: STATE_VISIBLE,
automation: STATE_VISIBLE,
broadcast: STATE_VISIBLE,
whatsapp_live_chat: STATE_VISIBLE,
promo_slides: STATE_VISIBLE,
customers: STATE_VISIBLE,
onu_stickers: STATE_VISIBLE,
packages: STATE_VISIBLE,
voucher_packages: STATE_VISIBLE,
billing: STATE_VISIBLE,
reports: STATE_VISIBLE,
cashiers_reports: STATE_VISIBLE,
collector_payments: STATE_VISIBLE,
tickets: STATE_VISIBLE,
inventory: STATE_LOCKED,
attendance: STATE_LOCKED,
payroll: STATE_LOCKED,
cash_in: STATE_VISIBLE,
expenses: STATE_VISIBLE,
expense_categories: STATE_VISIBLE,
cashier_attendance: STATE_VISIBLE,
technicians: STATE_LOCKED,
cashiers: STATE_LOCKED,
collectors: STATE_LOCKED,
areas: STATE_VISIBLE,
update: STATE_VISIBLE,
vouchers_discounts: STATE_VISIBLE,
discounts: STATE_VISIBLE,
service_notice: STATE_VISIBLE,
settings: STATE_VISIBLE,
ewallet_logs: STATE_VISIBLE,
backup: STATE_VISIBLE,
monitoring: STATE_VISIBLE,
audit_logs: STATE_VISIBLE
};
const SECTION_DEFINITIONS = [
{ key: 'main', labelKey: 'admin.section.main', labelDefault: 'UTAMA' },
{ key: 'billing', labelKey: 'admin.section.billing', labelDefault: 'BILLING' },
{ key: 'finance', labelKey: 'admin.section.finance', labelDefault: 'KEUANGAN' },
{ key: 'service', labelKey: 'admin.section.service', labelDefault: 'LAYANAN' },
{ key: 'cashier', labelKey: 'admin.section.cashier', labelDefault: 'KASIR' },
{ key: 'user_management', labelKey: 'admin.section.user_management', labelDefault: 'MANAJEMEN USER' },
{ key: 'system', labelKey: 'admin.section.system', labelDefault: 'SISTEM' }
];
function sha256(input) {
return crypto.createHash('sha256').update(String(input || '')).digest('hex');
}
function normalizeState(value) {
const normalized = String(value || '').trim().toLowerCase();
return VALID_STATES.has(normalized) ? normalized : STATE_VISIBLE;
}
function allMenuDefs() {
const out = [];
for (const m of MENU_DEFINITIONS) {
out.push(m);
if (Array.isArray(m.children)) { for (const c of m.children) out.push(c); }
}
return out;
}
function getStoredMenuStates() {
// Coba ambil dari Database dulu (Lebih Aman)
let raw = getAppSetting(SETTINGS_KEY, null);
let activationKeys = getAppSetting('sidebar_activation_keys', {});
// Fallback ke settings.json jika di DB masih kosong (Migration)
if (raw === null) {
raw = getSetting(SETTINGS_KEY, {});
activationKeys = getSetting('sidebar_activation_keys', {});
// Langsung migrasi ke DB agar kedepannya pakai DB
if (Object.keys(raw).length > 0) {
saveAppSetting(SETTINGS_KEY, raw);
saveAppSetting('sidebar_activation_keys', activationKeys);
}
}
const stateMap = {};
for (const menu of allMenuDefs()) {
const defaultState = DEFAULT_MENU_STATES[menu.key] || STATE_VISIBLE;
let storedState = raw && raw[menu.key] ? raw[menu.key] : defaultState;
const normalized = normalizeState(storedState);
// Jika menu aslinya LOCKED tapi diubah jadi VISIBLE/HIDDEN, cek kunci aktivasinya
if (defaultState === STATE_LOCKED && normalized !== STATE_LOCKED) {
const expectedKey = sha256(menu.key + getFeaturePasswordHash());
const providedKey = activationKeys[menu.key];
if (providedKey !== expectedKey) {
// Kunci tidak cocok! Kembalikan ke LOCKED
stateMap[menu.key] = STATE_LOCKED;
continue;
}
}
stateMap[menu.key] = normalized;
}
return stateMap;
}
function saveMenuStates(stateMap) {
const activationKeys = getAppSetting('sidebar_activation_keys', {});
const passwordHash = getFeaturePasswordHash();
for (const key in stateMap) {
const newState = stateMap[key];
const defaultState = DEFAULT_MENU_STATES[key] || STATE_VISIBLE;
// Jika menu yang aslinya LOCKED diaktifkan (jadi visible/hidden), generate kunci
if (defaultState === STATE_LOCKED && newState !== STATE_LOCKED) {
activationKeys[key] = sha256(key + passwordHash);
} else if (newState === STATE_LOCKED) {
// Jika dikunci kembali, hapus kuncinya
delete activationKeys[key];
}
}
// Simpan ke Database (Utama)
saveAppSetting(SETTINGS_KEY, sanitizeMenuStates(stateMap));
saveAppSetting('sidebar_activation_keys', activationKeys);
// Hanya gunakan database, tidak perlu backup ke settings.json
return true;
}
function sanitizeMenuStates(input) {
const clean = {};
for (const menu of allMenuDefs()) {
const defaultState = DEFAULT_MENU_STATES[menu.key] || STATE_VISIBLE;
let state = input && input[menu.key] ? input[menu.key] : defaultState;
clean[menu.key] = normalizeState(state);
}
return clean;
}
function isMenuAllowedForSession(menu, session) {
const roles = Array.isArray(menu.roles) ? menu.roles : ['admin'];
if (roles.includes('admin') && session && session.isAdmin) return true;
if (roles.includes('cashier') && session && session.isCashier) return true;
return false;
}
function enrichMenu(menu, states) {
const state = states[menu.key] || DEFAULT_MENU_STATES[menu.key] || STATE_VISIBLE;
const locked = state === STATE_LOCKED;
const hidden = state === STATE_HIDDEN;
const out = {
...menu,
state,
locked,
hidden,
hrefResolved: menu.href,
lockedMessage: locked ? `Menu "${menu.labelDefault}" terkunci. Hubungi ${getFeatureContactPhone()} untuk mendapatkan password aktivasi.` : ''
};
if (Array.isArray(menu.children)) {
out.children = menu.children.map((ch) => enrichMenu(ch, states));
}
return out;
}
function getSidebarSections(session) {
const states = getStoredMenuStates();
return SECTION_DEFINITIONS.map((section) => {
const items = MENU_DEFINITIONS
.filter((menu) => menu.section === section.key)
.filter((menu) => isMenuAllowedForSession(menu, session))
.map((menu) => {
const enriched = enrichMenu(menu, states);
if (Array.isArray(enriched.children)) {
enriched.children = enriched.children
.filter((ch) => isMenuAllowedForSession(ch, session))
.filter((ch) => !ch.hidden);
}
return enriched;
})
.filter((menu) => !menu.hidden);
return {
...section,
items
};
}).filter((section) => section.items.length > 0);
}
function getBottomNavItems(session) {
const states = getStoredMenuStates();
return MENU_DEFINITIONS
.filter((menu) => menu.bottomNav)
.filter((menu) => isMenuAllowedForSession(menu, session))
.map((menu) => enrichMenu(menu, states))
.filter((menu) => !menu.hidden)
.sort((a, b) => (a.bottomNavOrder || 99) - (b.bottomNavOrder || 99));
}
function getConfigMenus() {
const states = getStoredMenuStates();
return MENU_DEFINITIONS.map((menu) => {
const section = SECTION_DEFINITIONS.find((s) => s.key === menu.section);
return {
...menu,
state: states[menu.key] || DEFAULT_MENU_STATES[menu.key] || STATE_VISIBLE,
roleLabel: menu.roles.includes('admin') && menu.roles.includes('cashier')
? 'Admin & Kasir'
: menu.roles.includes('cashier')
? 'Kasir'
: 'Admin',
sectionLabel: section?.labelDefault || menu.section,
sectionLabelKey: section?.labelKey || ''
};
});
}
function getMenuDefinition(key) {
const top = MENU_DEFINITIONS.find((menu) => menu.key === key);
if (top) return top;
for (const menu of MENU_DEFINITIONS) {
if (Array.isArray(menu.children)) {
const ch = menu.children.find((c) => c.key === key);
if (ch) return ch;
}
}
return null;
}
function isFeaturePasswordValid(password) {
return sha256(password) === getFeaturePasswordHash();
}
function evaluateMenuAccess(menuKey, session) {
const menu = getMenuDefinition(menuKey);
if (!menu) {
return { allowed: true, state: STATE_VISIBLE, menu: null };
}
if (!isMenuAllowedForSession(menu, session)) {
return { allowed: false, state: 'forbidden', menu, reason: 'forbidden' };
}
const states = getStoredMenuStates();
const state = states[menu.key] || DEFAULT_MENU_STATES[menu.key] || STATE_VISIBLE;
if (state === STATE_HIDDEN) {
return { allowed: false, state, menu, reason: 'hidden' };
}
if (state === STATE_LOCKED) {
return { allowed: false, state, menu, reason: 'locked' };
}
return { allowed: true, state, menu, reason: null };
}
module.exports = {
getFeatureContactPhone,
getFeaturePasswordHash,
STATE_VISIBLE,
STATE_HIDDEN,
STATE_LOCKED,
MENU_DEFINITIONS,
getSidebarSections,
getBottomNavItems,
getConfigMenus,
getMenuDefinition,
getStoredMenuStates,
sanitizeMenuStates,
isFeaturePasswordValid,
saveMenuStates,
evaluateMenuAccess,
};
+113
View File
@@ -0,0 +1,113 @@
const db = require('../config/database');
function authenticate(username, password) {
const tech = db.prepare('SELECT * FROM technicians WHERE username = ? AND password = ? AND is_active = 1').get(username, password);
return tech;
}
function getTechById(id) {
return db.prepare('SELECT id, username, name, phone, area FROM technicians WHERE id = ?').get(id);
}
function getTechStats(techId) {
const total = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ?").get(techId).count;
const inProgress = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'in_progress'").get(techId).count;
const resolved = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'resolved'").get(techId).count;
// Ambil open tickets yang belum ada teknisinya atau di-assign ke teknisi ini
const open = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'open'").get().count;
return { total, open, inProgress, resolved };
}
function getAssignedTickets(techId) {
return db.prepare(`
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
FROM tickets t
JOIN customers c ON t.customer_id = c.id
WHERE t.technician_id = ? AND t.status != 'resolved'
ORDER BY t.created_at DESC
`).all(techId);
}
function getOpenTickets() {
return db.prepare(`
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
FROM tickets t
JOIN customers c ON t.customer_id = c.id
WHERE t.status = 'open'
ORDER BY t.created_at DESC
`).all();
}
function getResolvedTickets(techId) {
return db.prepare(`
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
FROM tickets t
JOIN customers c ON t.customer_id = c.id
WHERE t.technician_id = ? AND t.status = 'resolved'
ORDER BY t.updated_at DESC LIMIT 50
`).all(techId);
}
function takeTicket(ticketId, techId) {
let validTechId = Number(techId);
const exists = db.prepare('SELECT id FROM technicians WHERE id = ?').get(validTechId);
if (!exists) {
const fallback = db.prepare('SELECT id FROM technicians WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
if (fallback) validTechId = fallback.id;
}
db.prepare("UPDATE tickets SET technician_id = ?, status = 'in_progress', updated_at = (NOW_LOCAL()) WHERE id = ?").run(validTechId, ticketId);
return validTechId;
}
function updateTicketStatus(ticketId, techId, status, extraData = {}) {
const { notes, photos, photoMetadata } = extraData;
const updates = ['status = ?', 'updated_at = (NOW_LOCAL())'];
const params = [status];
if (techId) {
updates.push('technician_id = COALESCE(technician_id, ?)');
params.push(techId);
}
if (notes !== undefined) {
updates.push('technician_notes = ?');
params.push(notes);
}
if (photos !== undefined) {
updates.push('photos = ?');
params.push(photos);
}
if (photoMetadata !== undefined) {
updates.push('photo_metadata = ?');
params.push(photoMetadata);
}
params.push(ticketId);
const sql = `UPDATE tickets SET ${updates.join(', ')} WHERE id = ?`;
db.prepare(sql).run(...params);
}
// Admin helper
function getAllTechnicians() {
return db.prepare('SELECT id, username, name, phone, area, is_active FROM technicians').all();
}
function createTechnician(data) {
const stmt = db.prepare('INSERT INTO technicians (username, password, name, phone, area) VALUES (?, ?, ?, ?, ?)');
return stmt.run(data.username, data.password, data.name, data.phone || '', data.area || '');
}
module.exports = {
authenticate,
getTechById,
getTechStats,
getAssignedTickets,
getOpenTickets,
getResolvedTickets,
takeTicket,
updateTicketStatus,
getAllTechnicians,
createTechnician
};
File diff suppressed because it is too large. Load diff
+111
View File
@@ -0,0 +1,111 @@
const db = require('../config/database');
function getAllTickets(status = null, opts = {}) {
let query = `
SELECT t.*,
CASE WHEN t.customer_id IS NULL OR t.customer_id = 0 THEN 'Tugas Umum / Maintenance Admin' ELSE COALESCE(c.name, 'Pelanggan #' || t.customer_id) END as customer_name,
COALESCE(c.phone, '-') as customer_phone,
COALESCE(c.address, '-') as customer_address,
tech.name as technician_name
FROM tickets t
LEFT JOIN customers c ON (t.customer_id = c.id AND t.customer_id != 0)
LEFT JOIN technicians tech ON t.technician_id = tech.id
`;
const where = [];
const params = [];
if (status && status !== 'all') { where.push('t.status = ?'); params.push(status); }
const { search, technicianId } = opts || {};
if (search) {
where.push('(t.subject LIKE ? OR t.message LIKE ? OR c.name LIKE ? OR c.phone LIKE ?)');
const s = '%' + search + '%';
params.push(s, s, s, s);
}
if (technicianId) { where.push('t.technician_id = ?'); params.push(Number(technicianId)); }
const whereSql = where.length ? (' WHERE ' + where.join(' AND ')) : '';
query += whereSql + ` ORDER BY CASE WHEN t.status = 'open' THEN 1 WHEN t.status = 'in_progress' THEN 2 ELSE 3 END, t.created_at DESC`;
return db.prepare(query).all(...params);
}
function getTicketsByCustomerId(customerId) {
return db.prepare(`
SELECT t.*, tech.name as technician_name
FROM tickets t
LEFT JOIN technicians tech ON t.technician_id = tech.id
WHERE t.customer_id = ?
ORDER BY t.created_at DESC
`).all(customerId);
}
function getTicketById(id) {
return db.prepare(`
SELECT t.*,
CASE WHEN t.customer_id IS NULL OR t.customer_id = 0 THEN 'Tugas Umum / Maintenance Admin' ELSE COALESCE(c.name, 'Pelanggan #' || t.customer_id) END as customer_name,
COALESCE(c.phone, '-') as customer_phone,
COALESCE(c.address, '-') as customer_address,
tech.name as technician_name
FROM tickets t
LEFT JOIN customers c ON (t.customer_id = c.id AND t.customer_id != 0)
LEFT JOIN technicians tech ON t.technician_id = tech.id
WHERE t.id = ?
`).get(id);
}
function createTicket(customerId, subject, message, extraData = {}) {
const { customerPhotos, customerPhotoMetadata, technicianId, status } = extraData;
const custId = (customerId && parseInt(customerId, 10) > 0) ? parseInt(customerId, 10) : 0;
const techId = (technicianId && parseInt(technicianId, 10) > 0) ? parseInt(technicianId, 10) : null;
const initialStatus = status || (techId ? 'in_progress' : 'open');
return db.prepare(`
INSERT INTO tickets (customer_id, subject, message, status, technician_id, customer_photos, customer_photo_metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, (NOW_LOCAL()), (NOW_LOCAL()))
`).run(
custId,
subject,
message,
initialStatus,
techId,
customerPhotos || '',
customerPhotoMetadata || ''
);
}
function updateTicketStatus(id, status, technicianId = undefined) {
if (technicianId !== undefined) {
const techId = (technicianId && parseInt(technicianId, 10) > 0) ? parseInt(technicianId, 10) : null;
return db.prepare(`
UPDATE tickets
SET status = ?, technician_id = ?, updated_at = (NOW_LOCAL())
WHERE id = ?
`).run(status, techId, id);
} else {
return db.prepare(`
UPDATE tickets
SET status = ?, updated_at = (NOW_LOCAL())
WHERE id = ?
`).run(status, id);
}
}
function deleteTicket(id) {
return db.prepare('DELETE FROM tickets WHERE id = ?').run(id);
}
function getTicketStats() {
const open = db.prepare("SELECT COUNT(*) as c FROM tickets WHERE status='open'").get().c;
const inProgress = db.prepare("SELECT COUNT(*) as c FROM tickets WHERE status='in_progress'").get().c;
const resolved = db.prepare("SELECT COUNT(*) as c FROM tickets WHERE status='resolved'").get().c;
return { open, inProgress, resolved, total: open + inProgress + resolved };
}
module.exports = {
getAllTickets,
getTicketsByCustomerId,
getTicketById,
createTicket,
updateTicketStatus,
deleteTicket,
getTicketStats
};
+46
View File
@@ -0,0 +1,46 @@
/**
* Service: Pelacakan Pemakaian Kuota (Usage Tracking)
*/
const db = require('../config/database');
const { logger } = require('../config/logger');
function getUsage(customerId, month, year) {
return db.prepare('SELECT * FROM customer_usage WHERE customer_id = ? AND period_month = ? AND period_year = ?')
.get(customerId, month, year);
}
function updateUsage(customerId, deltaIn, deltaOut, totalIn, totalOut) {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
const existing = getUsage(customerId, month, year);
if (existing) {
return db.prepare(`
UPDATE customer_usage
SET bytes_in = bytes_in + ?,
bytes_out = bytes_out + ?,
last_total_bytes_in = ?,
last_total_bytes_out = ?,
updated_at = (NOW_LOCAL())
WHERE id = ?
`).run(deltaIn, deltaOut, totalIn, totalOut, existing.id);
} else {
return db.prepare(`
INSERT INTO customer_usage (customer_id, period_month, period_year, bytes_in, bytes_out, last_total_bytes_in, last_total_bytes_out)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(customerId, month, year, deltaIn, deltaOut, totalIn, totalOut);
}
}
function resetUsageCounter(customerId) {
const now = new Date();
return db.prepare(`
UPDATE customer_usage
SET last_total_bytes_in = 0, last_total_bytes_out = 0
WHERE customer_id = ? AND period_month = ? AND period_year = ?
`).run(customerId, now.getMonth() + 1, now.getFullYear());
}
module.exports = { getUsage, updateUsage, resetUsageCounter };
+256
View File
@@ -0,0 +1,256 @@
/**
* Voucher Cache Warmer Service
* Melakukan pre-warming cache untuk voucher profiles dan payment channels
* agar halaman voucher selalu cepat diakses
*/
const { logger } = require('../config/logger');
const { parseMikhmonOnLogin } = require('../utils/mikhmonParser');
// Flag untuk mencegah multiple warming berjalan bersamaan
let isWarming = false;
let warmingInterval = null;
/**
* Warm up voucher cache
* Dipanggil secara berkala untuk memastikan cache selalu fresh
*/
async function warmVoucherCache() {
if (isWarming) {
logger.debug('[VoucherCacheWarmer] Already warming, skip');
return;
}
isWarming = true;
const startTime = Date.now();
try {
// Import di dalam fungsi untuk menghindari circular dependency
const mikrotikService = require('./mikrotikService');
const paymentSvc = require('./paymentService');
const { getSettingsWithCache } = require('../config/settingsManager');
const db = require('../config/database');
logger.info('[VoucherCacheWarmer] Starting cache warming...');
// Get settings
const settings = getSettingsWithCache();
// 1. Warm voucher profiles cache
const CACHE_KEY = 'voucher_profiles_cache';
const routers = mikrotikService.getAllRouters().filter(r => r.is_active);
const routerList = routers.length > 0 ? routers : [{ id: null, name: '' }];
// Query database dan MikroTik secara parallel
const [configuredPricesMap, mikrotikResults] = await Promise.all([
(async () => {
const map = new Map();
try {
const rows = db.prepare(`
SELECT router_id, profile_name, price, validity
FROM voucher_batches
WHERE price > 0
GROUP BY router_id, profile_name
HAVING id = MAX(id)
`).all();
for (const row of rows) {
const key = `${row.router_id}_${row.profile_name}`;
map.set(key, { price: row.price, validity: row.validity });
}
} catch (e) {
logger.error('[VoucherCacheWarmer] Error loading configured prices: ' + e.message);
}
return map;
})(),
(async () => {
try {
// Query semua router secara parallel dengan timeout per router
const results = await Promise.allSettled(
routerList.map(r =>
Promise.race([
mikrotikService.getHotspotUserProfiles(r.id),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout for router ${r.name || r.id}`)), 4000)
)
])
)
);
return results;
} catch (e) {
logger.error('[VoucherCacheWarmer] Error querying MikroTik: ' + e.message);
return [];
}
})()
]);
// Process profiles
const allRows = [];
if (Array.isArray(mikrotikResults)) {
for (let i = 0; i < mikrotikResults.length; i++) {
const result = mikrotikResults[i];
const router = routerList[i];
if (result.status !== 'fulfilled' || !Array.isArray(result.value)) {
logger.warn(`[VoucherCacheWarmer] Router ${router.name || router.id} query failed or returned invalid data`);
continue;
}
for (const p of result.value) {
allRows.push({
routerId: router.id ?? null,
name: p?.name,
onLogin: p?.onLogin ?? p?.['on-login']
});
}
}
}
logger.info(`[VoucherCacheWarmer] Found ${allRows.length} profiles from MikroTik`);
const bestByName = new Map();
let skippedCount = 0;
for (const row of allRows) {
const name = String(row.name || '').trim();
if (!name) continue;
// Parse Mikhmon metadata (gunakan shared parser agar konsisten dengan route handler)
const onLogin = String(row.onLogin || '').trim();
const meta = parseMikhmonOnLogin(onLogin);
let price = Number(meta?.price || 0) || 0;
let validity = String(meta?.validity || '').trim();
if (price <= 0 || !validity) {
const key = `${row.routerId}_${name}`;
const configured = configuredPricesMap.get(key);
if (configured) {
price = Number(configured.price || 0) || 0;
validity = String(configured.validity || '').trim();
}
}
if (price <= 0) {
skippedCount++;
logger.debug(`[VoucherCacheWarmer] Skipped profile "${name}" - no price (onLogin: ${onLogin || 'empty'})`);
continue;
}
if (!validity) validity = '-';
const existing = bestByName.get(name);
if (!existing || Number(price) < Number(existing.price || 0)) {
bestByName.set(name, { name, validity, price, router_id: row.routerId });
}
}
if (skippedCount > 0) {
logger.warn(`[VoucherCacheWarmer] Skipped ${skippedCount} profiles without price metadata. Add Mikhmon metadata (e.g., $10000^1d) to profile's on-login script or configure price in voucher_batches table.`);
}
const profiles = Array.from(bestByName.values()).sort((a, b) => Number(a.price || 0) - Number(b.price || 0));
// Save to cache
global[CACHE_KEY] = {
data: profiles,
timestamp: Date.now()
};
logger.info(`[VoucherCacheWarmer] Profiles cached: ${profiles.length} profiles`);
// 2. Warm payment channels cache
const gateway = settings.default_gateway || 'tripay';
const gatewayEnabled = {
tripay: settings.tripay_enabled && settings.tripay_api_key,
midtrans: settings.midtrans_enabled && settings.midtrans_server_key,
xendit: settings.xendit_enabled && settings.xendit_api_key,
duitku: settings.duitku_enabled && settings.duitku_api_key
};
if (gatewayEnabled[gateway]) {
const PAYMENT_CACHE_KEY = `voucher_payment_channels_cache_${gateway}`;
let channels = [];
if (gateway === 'tripay') {
try {
channels = await Promise.race([
paymentSvc.getTripayChannels(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 1500))
]);
} catch (e) {
logger.warn('[VoucherCacheWarmer] Tripay channels fetch failed: ' + e.message);
channels = [];
}
} else {
const base = [
{ code: 'QRIS', name: 'QRIS', group: 'QRIS', active: true },
{ code: 'BCAVA', name: 'BCA Virtual Account', group: 'Virtual Account', active: true },
{ code: 'BNIVA', name: 'BNI Virtual Account', group: 'Virtual Account', active: true },
{ code: 'BRIVA', name: 'BRI Virtual Account', group: 'Virtual Account', active: true },
{ code: 'PERMATAVA', name: 'Permata Virtual Account', group: 'Virtual Account', active: true },
{ code: 'MANDIRIVA', name: 'Mandiri Virtual Account', group: 'Virtual Account', active: true }
];
if (gateway === 'midtrans') channels = [{ code: 'SNAP', name: 'Semua Metode (Snap)', group: 'E-Wallet', active: true }, ...base];
else if (gateway === 'xendit') channels = [{ code: 'XENDIT', name: 'Semua Metode', group: 'E-Wallet', active: true }, ...base];
else if (gateway === 'duitku') channels = [{ code: 'DUITKU', name: 'Semua Metode', group: 'E-Wallet', active: true }, ...base];
else channels = base;
}
global[PAYMENT_CACHE_KEY] = {
data: channels,
timestamp: Date.now()
};
logger.info(`[VoucherCacheWarmer] Payment channels cached: ${channels.length} channels`);
}
const duration = Date.now() - startTime;
logger.info(`[VoucherCacheWarmer] Cache warming completed in ${duration}ms`);
} catch (error) {
logger.error('[VoucherCacheWarmer] Error warming cache:', error.message);
} finally {
isWarming = false;
}
}
/**
* Start background cache warming
* Warm cache setiap 8 menit (sebelum cache 10 menit expired)
*/
function startCacheWarming() {
if (warmingInterval) {
logger.warn('[VoucherCacheWarmer] Already started');
return;
}
// Warm immediately on start (500ms delay agar app sudah siap)
setTimeout(() => {
warmVoucherCache().catch(err => {
logger.error('[VoucherCacheWarmer] Initial warming failed:', err.message);
});
}, 500); // 500ms setelah aplikasi start (lebih cepat siap)
// Then warm every 8 minutes
warmingInterval = setInterval(() => {
warmVoucherCache().catch(err => {
logger.error('[VoucherCacheWarmer] Periodic warming failed:', err.message);
});
}, 3 * 60 * 1000); // 3 menit (agar profile selalu up-to-date)
logger.info('[VoucherCacheWarmer] Background cache warming started (every 3 minutes)');
}
/**
* Stop background cache warming
*/
function stopCacheWarming() {
if (warmingInterval) {
clearInterval(warmingInterval);
warmingInterval = null;
logger.info('[VoucherCacheWarmer] Background cache warming stopped');
}
}
module.exports = {
warmVoucherCache,
startCacheWarming,
stopCacheWarming
};
// Made with Bob
+314
View File
@@ -0,0 +1,314 @@
/**
* Service: Voucher Payment Gateway Integration
* Otomatis mengambil payment methods dari payment gateway yang aktif
*/
const axios = require('axios');
const { getSettingsWithCache } = require('../config/settingsManager');
const { logger } = require('../config/logger');
const paymentSvc = require('./paymentService');
/**
* Get available payment methods dari payment gateway yang aktif
*/
async function getAvailablePaymentMethods() {
// MODE-AWARE: gateway => Mayar | selfservice => Manual (QRIS statis)
const _s = getSettingsWithCache();
const _mode = String(_s.payment_mode || 'selfservice').toLowerCase();
if (_mode === 'gateway' && String(_s.mayar_api_key || '').trim()) {
return [{ gateway: 'mayar', code: 'MAYAR', name: 'Mayar (QRIS/VA/E-Wallet)', group: 'Pembayaran Online' }];
}
return [{ gateway: 'manual', code: 'MANUAL', name: 'QRIS Manual (transfer + unggah bukti)', group: 'Manual' }];
/* eslint-disable no-unreachable */
/* kode lama (gateway) tidak dijalankan */
const settings = getSettingsWithCache();
const methods = [];
try {
// Tripay methods
if (settings.tripay_enabled && settings.tripay_api_key && settings.tripay_private_key && settings.tripay_merchant_code) {
const tripayMethods = await getTripayPaymentMethods(settings);
methods.push(...tripayMethods);
}
// Midtrans methods (Snap)
if (settings.midtrans_enabled && settings.midtrans_server_key) {
const midtransMethods = await getMidtransPaymentMethods(settings);
methods.push(...midtransMethods);
}
// Xendit methods
if (settings.xendit_enabled && settings.xendit_api_key) {
const xenditMethods = await getXenditPaymentMethods(settings);
methods.push(...xenditMethods);
}
// Duitku methods
if (settings.duitku_enabled && settings.duitku_api_key && settings.duitku_merchant_code) {
const duitkuMethods = await getDuitkuPaymentMethods(settings);
methods.push(...duitkuMethods);
}
return methods;
} catch (error) {
logger.error('[VoucherPayment] Error getting payment methods:', error.message);
return [];
}
}
/**
* Get Tripay payment methods
*/
async function getTripayPaymentMethods(settings) {
try {
const isLive = settings.tripay_mode === 'live' || settings.tripay_mode === 'production';
const baseUrl = isLive
? 'https://tripay.co.id/api/merchant/payment-method'
: 'https://tripay.co.id/api-sandbox/merchant/payment-method';
const response = await axios.get(baseUrl, {
headers: { Authorization: `Bearer ${settings.tripay_api_key}` },
timeout: 5000
});
if (response.data && response.data.success && Array.isArray(response.data.data)) {
return response.data.data.map(method => ({
gateway: 'tripay',
code: method.code,
name: method.name,
icon: method.icon_url || '',
fee: method.fee_flat || 0,
feePercent: method.fee_percent || 0,
minAmount: method.min_amount || 0,
maxAmount: method.max_amount || 0,
active: method.active === true || method.active === 1
})).filter(m => m.active);
}
return [];
} catch (error) {
logger.warn('[VoucherPayment] Tripay methods fetch failed:', error.message);
return [];
}
}
/**
* Get Midtrans payment methods (hardcoded, Midtrans tidak provide API untuk list methods)
*/
async function getMidtransPaymentMethods(settings) {
const methods = [
{ code: 'credit_card', name: 'Kartu Kredit', icon: '💳' },
{ code: 'bank_transfer', name: 'Transfer Bank', icon: '🏦' },
{ code: 'echannel', name: 'ATM Bersama/Prima', icon: '🏧' },
{ code: 'bca_va', name: 'BCA Virtual Account', icon: '🏦' },
{ code: 'bni_va', name: 'BNI Virtual Account', icon: '🏦' },
{ code: 'bri_va', name: 'BRI Virtual Account', icon: '🏦' },
{ code: 'permata_va', name: 'Permata Virtual Account', icon: '🏦' },
{ code: 'other_va', name: 'Virtual Account Lainnya', icon: '🏦' },
{ code: 'gopay', name: 'GoPay', icon: '📱' },
{ code: 'qris', name: 'QRIS', icon: '📲' }
];
return methods.map(method => ({
gateway: 'midtrans',
code: method.code,
name: method.name,
icon: method.icon,
fee: 0,
feePercent: 0,
minAmount: 10000,
maxAmount: 999999999,
active: true
}));
}
/**
* Get Xendit payment methods
*/
async function getXenditPaymentMethods(settings) {
try {
const methods = [
{ code: 'BANK_TRANSFER', name: 'Transfer Bank', icon: '🏦' },
{ code: 'EWALLET', name: 'E-Wallet (OVO, Dana, LinkAja)', icon: '📱' },
{ code: 'RETAIL_OUTLET', name: 'Retail Outlet (Indomaret, Alfamart)', icon: '🏪' },
{ code: 'CREDIT_CARD', name: 'Kartu Kredit', icon: '💳' },
{ code: 'QR_CODE', name: 'QRIS', icon: '📲' }
];
return methods.map(method => ({
gateway: 'xendit',
code: method.code,
name: method.name,
icon: method.icon,
fee: 0,
feePercent: 0,
minAmount: 1000,
maxAmount: 999999999,
active: true
}));
} catch (error) {
logger.warn('[VoucherPayment] Xendit methods fetch failed:', error.message);
return [];
}
}
/**
* Get Duitku payment methods
*/
async function getDuitkuPaymentMethods(settings) {
try {
const methods = [
{ code: 'OV', name: 'Outlet Retail', icon: '🏪' },
{ code: 'BT', name: 'Transfer Bank', icon: '🏦' },
{ code: 'CC', name: 'Kartu Kredit', icon: '💳' },
{ code: 'QRIS', name: 'QRIS', icon: '📲' },
{ code: 'EWALLET', name: 'E-Wallet', icon: '📱' }
];
return methods.map(method => ({
gateway: 'duitku',
code: method.code,
name: method.name,
icon: method.icon,
fee: 0,
feePercent: 0,
minAmount: 1000,
maxAmount: 999999999,
active: true
}));
} catch (error) {
logger.warn('[VoucherPayment] Duitku methods fetch failed:', error.message);
return [];
}
}
/**
* Create voucher payment transaction
*/
async function createVoucherPayment(voucherOrder, paymentMethod, appUrl = '') {
const settings = getSettingsWithCache();
if (!voucherOrder || !voucherOrder.id) {
throw new Error('Voucher order tidak valid');
}
if (!paymentMethod || !paymentMethod.gateway) {
throw new Error('Payment method tidak valid');
}
const gateway = paymentMethod.gateway.toLowerCase();
// Prepare invoice object
const invoice = {
id: voucherOrder.id,
amount: voucherOrder.price,
item_name: `Voucher Hotspot ${voucherOrder.profile_name}`,
sku: `VOUCHER-${voucherOrder.id}`
};
// Prepare customer object
const customer = {
name: 'Pembeli Voucher',
phone: voucherOrder.buyer_phone,
email: ''
};
// Prepare options with explicit VOUCHER orderPrefix
const opts = {
orderPrefix: 'VOUCHER',
itemName: `Voucher Hotspot ${voucherOrder.profile_name}`,
sku: `VOUCHER-${voucherOrder.id}`,
callbackPath: '/customer/payment/callback',
returnPath: `/customer/voucher?order=${voucherOrder.id}`
};
try {
let result;
if (gateway === 'mayar') {
const mayar = require('./mayarService');
const r = await mayar.createInvoice({
name: customer.name,
email: customer.email,
mobile: customer.phone,
description: opts.itemName,
amount: invoice.amount,
extraData: { voucher_order_id: String(voucherOrder.id), type: 'voucher' }
});
return { success: true, gateway: 'mayar', paymentMethod: 'MAYAR', link: r.link, reference: r.reference || r.order_id, orderId: r.order_id, payload: r.payload };
}
if (gateway === 'manual') {
return { success: true, gateway: 'manual', paymentMethod: 'MANUAL', link: '', reference: '', orderId: '', payload: null, manual: true };
}
switch (gateway) {
case 'tripay':
result = await paymentSvc.createTripayTransaction(invoice, customer, paymentMethod.code, appUrl, opts);
break;
case 'midtrans':
result = await paymentSvc.createMidtransTransaction(invoice, customer, 'snap', appUrl, opts);
break;
case 'xendit':
result = await paymentSvc.createXenditTransaction(invoice, customer, paymentMethod.code, appUrl, opts);
break;
case 'duitku':
result = await paymentSvc.createDuitkuTransaction(invoice, customer, paymentMethod.code, appUrl, opts);
break;
default:
throw new Error(`Gateway ${gateway} tidak didukung`);
}
return {
success: true,
gateway,
paymentMethod: paymentMethod.code,
link: result.link,
reference: result.reference || result.order_id,
orderId: result.order_id,
payload: result.payload
};
} catch (error) {
logger.error(`[VoucherPayment] Error creating payment (${gateway}):`, error.message);
throw error;
}
}
/**
* Get default payment gateway
*/
function getDefaultPaymentGateway() {
const _s = getSettingsWithCache();
const _mode = String(_s.payment_mode || 'selfservice').toLowerCase();
return (_mode === 'gateway' && String(_s.mayar_api_key || '').trim()) ? 'mayar' : 'manual';
/* eslint-disable no-unreachable */
const settings = getSettingsWithCache();
const defaultGateway = settings.default_gateway || 'tripay';
const gatewaySettings = {
tripay: { enabled: settings.tripay_enabled, key: settings.tripay_api_key },
midtrans: { enabled: settings.midtrans_enabled, key: settings.midtrans_server_key },
xendit: { enabled: settings.xendit_enabled, key: settings.xendit_api_key },
duitku: { enabled: settings.duitku_enabled, key: settings.duitku_api_key }
};
if (gatewaySettings[defaultGateway]?.enabled && gatewaySettings[defaultGateway]?.key) {
return defaultGateway;
}
for (const [gateway, config] of Object.entries(gatewaySettings)) {
if (config.enabled && config.key) {
return gateway;
}
}
return null;
}
module.exports = {
getAvailablePaymentMethods,
getTripayPaymentMethods,
getMidtransPaymentMethods,
getXenditPaymentMethods,
getDuitkuPaymentMethods,
createVoucherPayment,
getDefaultPaymentGateway
};
+73
View File
@@ -0,0 +1,73 @@
/**
* Service: PDF Voucher Batch (PDFKit, A4 portrait) — tema bersama (pdfBranding).
*/
const PDFDocument = require('pdfkit');
const fs = require('fs');
const { THEME, resolveLogoPath, companyInfo, fmtRp } = require('./pdfBranding');
function generateVoucherBatchPdfBuffer(batch, vouchers, settings = {}) {
return new Promise((resolve, reject) => {
try {
const ci = companyInfo(settings);
const doc = new PDFDocument({ size: 'A4', margin: 0, bufferPages: true, info: { Title: `Voucher Batch #${batch.id}`, Author: ci.name, Subject: 'Voucher Hotspot' } });
const bufs = [];
doc.on('data', b => bufs.push(b)); doc.on('end', () => resolve(Buffer.concat(bufs))); doc.on('error', reject);
const { INK, MUTED, LINE, SOFT, GREEN, RED, AMBER, BLUE, ACCENT, LIGHT } = THEME;
const LEFT = 40, RIGHT = 555, W = RIGHT - LEFT;
const HEADH = 74;
const list = Array.isArray(vouchers) ? vouchers : [];
const price = Number(batch.price || 0);
function drawHeader() {
doc.rect(0, 0, 595.28, HEADH).fill(INK); doc.rect(0, HEADH, 595.28, 3).fill(ACCENT);
let x = LEFT; const lp = resolveLogoPath(settings);
if (lp && fs.existsSync(lp)) { try { doc.image(lp, LEFT, 18, { fit: [110, 34] }); x = LEFT + 122; } catch (e) {} }
doc.fillColor('#ffffff').fontSize(15).font('Helvetica-Bold').text(ci.name, x, 18, { width: 205, lineBreak: false });
doc.fillColor(LIGHT).fontSize(8).font('Helvetica').text(ci.address, x, 37, { width: 205, height: 11, ellipsis: true });
doc.fillColor('#ffffff').fontSize(15).font('Helvetica-Bold').text('VOUCHER', LEFT, 18, { width: W, align: 'right' });
doc.fillColor(ACCENT).fontSize(9).font('Helvetica-Bold').text(`Batch #${batch.id} • ${batch.profile_name || ''}`, LEFT, 40, { width: W, align: 'right' });
return HEADH + 3;
}
let y = drawHeader() + 14;
doc.fillColor(MUTED).fontSize(8).font('Helvetica').text(
`Profil: ${batch.profile_name || '-'} • Harga: ${fmtRp(price)} • Masa aktif: ${batch.validity || '-'} • Router: ${batch.router_name || '-'} • Jumlah: ${list.length} • Dicetak: ${new Date().toLocaleString('id-ID')}`,
LEFT, y, { width: W });
y += 22;
const cols = 3, gap = 8;
const cardW = (W - (cols - 1) * gap) / cols;
const cardH = 74, rowGap = 8;
let col = 0;
for (let i = 0; i < list.length; i++) {
const v = list[i];
if (col === 0 && y + cardH > 758) { doc.addPage(); y = drawHeader() + 14; }
const cx = LEFT + col * (cardW + gap);
doc.roundedRect(cx, y, cardW, cardH, 8).fillAndStroke('#ffffff', LINE);
doc.rect(cx, y, cardW, 15).fill(INK);
doc.fillColor('#ffffff').fontSize(7).font('Helvetica-Bold').text(ci.name, cx + 7, y + 4, { width: cardW - 14, ellipsis: true, lineBreak: false });
doc.fillColor(INK).fontSize(13).font('Helvetica-Bold').text(String(v.code || '-'), cx + 6, y + 22, { width: cardW - 12, align: 'center' });
doc.fillColor(MUTED).fontSize(8).font('Helvetica').text('PIN: ' + String(v.password || '-'), cx + 6, y + 41, { width: cardW - 12, align: 'center' });
const sub = `${batch.profile_name || ''} • ${fmtRp(price)}${batch.validity ? (' • ' + batch.validity) : ''}`;
doc.fillColor(AMBER).fontSize(7).font('Helvetica-Bold').text(sub, cx + 5, y + 58, { width: cardW - 10, align: 'center', ellipsis: true, lineBreak: false });
col++;
if (col >= cols) { col = 0; y += cardH + rowGap; }
}
if (list.length === 0) {
doc.fillColor(MUTED).fontSize(10).font('Helvetica').text('Belum ada voucher pada batch ini.', LEFT, y, { width: W, align: 'center' });
}
// Footer + nomor halaman
const range = doc.bufferedPageRange();
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
doc.fillColor(MUTED).fontSize(7).font('Helvetica').text(`${ci.name} • Voucher Batch #${batch.id} • Halaman ${i - range.start + 1}/${range.count}`, LEFT, 812, { width: W, align: 'center' });
}
doc.end();
} catch (e) { reject(e); }
});
}
module.exports = { generateVoucherBatchPdfBuffer };
+87
View File
@@ -0,0 +1,87 @@
'use strict';
/**
* voucherService.js — Voucher diskon registrasi.
* Voucher: berlaku selamanya (tanpa kedaluwarsa), hanya batas nominal.
* type 'nominal' = potongan Rp; 'percent' = % dengan batas maksimal (max_discount).
*/
const db = require('../config/database');
function list() {
return db.prepare('SELECT * FROM discount_vouchers ORDER BY id DESC').all();
}
function getByCode(code) {
const c = String(code || '').trim();
if (!c) return null;
return db.prepare('SELECT * FROM discount_vouchers WHERE code = ? COLLATE NOCASE').get(c);
}
function getById(id) {
return db.prepare('SELECT * FROM discount_vouchers WHERE id = ?').get(Number(id));
}
function computeDiscount(v, amount) {
const amt = Math.max(0, Math.round(Number(amount) || 0));
if (!v || !v.active) return 0;
if (Number(v.max_uses) > 0 && Number(v.used_count) >= Number(v.max_uses)) return 0;
if (Number(v.min_purchase) > 0 && amt < Number(v.min_purchase)) return 0;
let d;
if (v.type === 'percent') {
d = Math.round(amt * (Number(v.value) || 0) / 100);
if (Number(v.max_discount) > 0) d = Math.min(d, Number(v.max_discount));
} else {
d = Number(v.value) || 0;
}
return Math.max(0, Math.min(d, amt));
}
function validate(code, amount) {
const v = getByCode(code);
if (!v) return { ok: false, message: 'Kode voucher tidak ditemukan.' };
if (!v.active) return { ok: false, message: 'Kode voucher tidak aktif.' };
if (Number(v.max_uses) > 0 && Number(v.used_count) >= Number(v.max_uses)) return { ok: false, message: 'Kode voucher sudah habis dipakai.' };
const amt = Math.max(0, Math.round(Number(amount) || 0));
if (Number(v.min_purchase) > 0 && amt < Number(v.min_purchase)) {
return { ok: false, message: 'Minimal tagihan Rp ' + Number(v.min_purchase).toLocaleString('id-ID') + ' untuk memakai voucher ini.' };
}
const discount = computeDiscount(v, amt);
return { ok: true, voucher: v, discount: discount, message: 'Voucher diterapkan: -Rp ' + discount.toLocaleString('id-ID') };
}
function create(data) {
const code = String(data.code || '').trim().toUpperCase();
if (!code) throw new Error('Kode voucher wajib diisi');
const type = data.type === 'percent' ? 'percent' : 'nominal';
const value = Math.max(0, parseInt(data.value, 10) || 0);
if (value <= 0) throw new Error('Nilai voucher harus lebih dari 0');
const maxDiscount = Math.max(0, parseInt(data.max_discount, 10) || 0);
const minPurchase = Math.max(0, parseInt(data.min_purchase, 10) || 0);
const maxUses = Math.max(0, parseInt(data.max_uses, 10) || 0);
const active = Number(data.active) === 0 ? 0 : 1;
const exists = getByCode(code);
if (exists) throw new Error('Kode voucher sudah ada');
const r = db.prepare(`INSERT INTO discount_vouchers (code, type, value, max_discount, min_purchase, active, max_uses, note) VALUES (?,?,?,?,?,?,?,?)`)
.run(code, type, value, maxDiscount, minPurchase, active, maxUses, String(data.note || ''));
return getById(r.lastInsertRowid);
}
function update(id, data) {
const v = getById(id); if (!v) throw new Error('Voucher tidak ditemukan');
const code = data.code != null ? String(data.code).trim().toUpperCase() : v.code;
if (!code) throw new Error('Kode voucher wajib diisi');
const dup = getByCode(code);
if (dup && Number(dup.id) !== Number(id)) throw new Error('Kode voucher sudah dipakai voucher lain');
const type = (data.type === 'percent') ? 'percent' : (data.type === 'nominal' ? 'nominal' : v.type);
const value = data.value != null ? Math.max(0, parseInt(data.value, 10) || 0) : v.value;
const maxDiscount = data.max_discount != null ? Math.max(0, parseInt(data.max_discount, 10) || 0) : v.max_discount;
const minPurchase = data.min_purchase != null ? Math.max(0, parseInt(data.min_purchase, 10) || 0) : v.min_purchase;
const maxUses = data.max_uses != null ? Math.max(0, parseInt(data.max_uses, 10) || 0) : v.max_uses;
const active = data.active != null ? (Number(data.active) === 1 ? 1 : 0) : v.active;
const note = data.note != null ? String(data.note) : v.note;
db.prepare(`UPDATE discount_vouchers SET code=?, type=?, value=?, max_discount=?, min_purchase=?, active=?, max_uses=?, note=? WHERE id=?`)
.run(code, type, value, maxDiscount, minPurchase, active, maxUses, note, Number(id));
return getById(id);
}
function remove(id) {
return db.prepare('DELETE FROM discount_vouchers WHERE id=?').run(Number(id));
}
function incrementUse(code) {
const c = String(code || '').trim();
if (!c) return;
db.prepare('UPDATE discount_vouchers SET used_count = used_count + 1 WHERE code = ? COLLATE NOCASE').run(c);
}
module.exports = { list, getByCode, getById, computeDiscount, validate, create, update, remove, incrementUse };
+59
View File
@@ -0,0 +1,59 @@
const fs = require('fs');
const path = require('path');
/**
* Menyimpan pemetaan JID (@lid atau @s.whatsapp.net) → tag GenieACS yang dipakai API.
* Diperlukan karena pengirim sering tampil sebagai …@lid, bukan nomor.
*/
class WaLidStore {
constructor(filePath) {
this.filePath = filePath;
this.map = Object.create(null);
this._load();
}
_load() {
try {
if (fs.existsSync(this.filePath)) {
const raw = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
this.map = raw && typeof raw === 'object' ? raw : Object.create(null);
}
} catch (e) {
this.map = Object.create(null);
}
}
_save() {
const dir = path.dirname(this.filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.filePath, JSON.stringify(this.map, null, 2), 'utf8');
}
get(jid) {
if (!jid || typeof jid !== 'string') return null;
const v = this.map[jid.toLowerCase()];
return v != null ? String(v) : null;
}
set(jid, canonicalTag) {
if (!jid || canonicalTag == null || canonicalTag === '') return;
this.map[jid.toLowerCase()] = String(canonicalTag);
this._save();
}
/** Cari JID berdasarkan tag (reverse lookup) */
getByTag(canonicalTag) {
if (!canonicalTag) return null;
const target = String(canonicalTag).toLowerCase();
for (const [jid, tag] of Object.entries(this.map)) {
if (String(tag).toLowerCase() === target) {
return jid;
}
}
return null;
}
}
module.exports = { WaLidStore };
+148
View File
@@ -0,0 +1,148 @@
'use strict';
/**
* waNotifyService.js — kirim notifikasi WA mengikuti gateway aktif
* (gowa / baileys / fonnte / meta). Mendukung teks, gambar, dan dokumen.
*/
const fs = require('fs');
const path = require('path');
const { getSetting } = require('../config/settingsManager');
function gateway() {
return String(getSetting('wa_gateway_type', 'baileys') || 'baileys').toLowerCase();
}
function publicBaseUrl() {
let base = String(getSetting('public_base_url', '') || '').trim();
if (base) return base.replace(/\/+$/, '');
const host = String(getSetting('server_host', '') || '').trim();
const port = Number(getSetting('server_port', 3001) || 3001);
if (!host) return '';
const proto = port === 443 ? 'https' : 'http';
const h = /^https?:\/\//i.test(host) ? host.replace(/\/+$/, '') : (proto + '://' + host);
return (port === 80 || port === 443) ? h : (h + ':' + port);
}
function saveTemp(buffer, ext) {
try {
const dir = path.join(__dirname, '..', 'public', 'uploads', 'notif');
fs.mkdirSync(dir, { recursive: true });
const name = 'notif-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '.' + (ext || 'bin');
fs.writeFileSync(path.join(dir, name), buffer);
const base = publicBaseUrl();
return (base ? base : '') + '/uploads/notif/' + name;
} catch (e) { return ''; }
}
async function sendText(phone, text) {
try {
const whatsappService = require('./whatsappService');
await whatsappService.sendWhatsAppMessage(phone, text);
return true;
} catch (e) { return false; }
}
async function sendMetaImage(phone, buffer, caption) {
try {
const phoneId = getSetting('meta_phone_number_id', '');
const token = getSetting('meta_access_token', '');
if (!phoneId || !token) return await sendText(phone, caption || '');
const fd = new FormData();
fd.append('messaging_product', 'whatsapp');
fd.append('type', 'image/png');
fd.append('file', new Blob([buffer], { type: 'image/png' }), 'qris.png');
const up = await fetch('https://graph.facebook.com/v20.0/' + phoneId + '/media', { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: fd });
const upj = await up.json().catch(() => null);
if (!up.ok || !upj || !upj.id) return await sendText(phone, caption || '');
let digits = String(phone).replace(/\D/g, '');
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
const send = await fetch('https://graph.facebook.com/v20.0/' + phoneId + '/messages', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ messaging_product: 'whatsapp', to: digits, type: 'image', image: { id: upj.id, caption: caption || '' } })
});
return send.ok;
} catch (e) { return false; }
}
async function sendImage(phone, imageBuffer, caption) {
const gw = gateway();
try {
if (gw === 'gowa') { await require('./gowaWhatsappService').sendGowaImage(phone, imageBuffer, caption || ''); return true; }
if (gw === 'baileys') { const mod = await import('./whatsappBot.mjs'); return await mod.sendWAImage(phone, imageBuffer, caption || ''); }
if (gw === 'fonnte') {
const url = saveTemp(imageBuffer, 'png');
if (!url) return await sendText(phone, caption || '');
const fonnte = require('./fonnteWhatsappService');
await fonnte.sendFonnteMessage(phone, caption || '', { url: url, filename: 'qris.png' });
return true;
}
if (gw === 'meta') return await sendMetaImage(phone, imageBuffer, caption);
return await sendText(phone, caption || '');
} catch (e) { return false; }
}
async function sendDocument(phone, docBuffer, filename, caption, mimetype) {
const gw = gateway();
const fn = filename || 'file.pdf';
try {
if (gw === 'gowa') { await require('./gowaWhatsappService').sendGowaDocument(phone, docBuffer, fn, caption || '', mimetype || 'application/pdf'); return true; }
if (gw === 'baileys') { const mod = await import('./whatsappBot.mjs'); return await mod.sendWADocument(phone, docBuffer, fn, caption || '', mimetype || 'application/pdf'); }
if (gw === 'fonnte') {
const ext = (fn.split('.').pop() || 'bin');
const url = saveTemp(docBuffer, ext);
if (!url) return await sendText(phone, caption || '');
const fonnte = require('./fonnteWhatsappService');
await fonnte.sendFonnteMessage(phone, caption || '', { url: url, filename: fn });
return true;
}
// meta + lain: fallback teks
return await sendText(phone, caption || '');
} catch (e) { return false; }
}
function normalizeNum(p) {
let d = String(p || '').replace(/\D/g, '');
if (!d) return '';
if (d.startsWith('0')) d = '62' + d.slice(1);
return d.length >= 8 ? d : '';
}
async function sendToStaff(text) {
const set = new Set();
try {
const admins = getSetting('whatsapp_admin_numbers', []);
(Array.isArray(admins) ? admins : String(admins || '').split(',')).forEach((p) => { const n = normalizeNum(p); if (n) set.add(n); });
} catch (e) {}
try {
const techs = require('./techService').getAllTechnicians() || [];
techs.filter((t) => t.is_active === 1 && t.phone).forEach((t) => { const n = normalizeNum(t.phone); if (n) set.add(n); });
} catch (e) {}
let sent = 0;
for (const p of set) { if (await sendText(p, text)) sent++; }
return { sent: sent, total: set.size };
}
let _connCache = { at: 0, val: false };
async function isConnected() {
const now = Date.now();
if (now - _connCache.at < 5000) return _connCache.val;
const gw = gateway();
let val = false;
try {
if (gw === 'gowa') {
const st = await require('./gowaWhatsappService').gowaStatus();
const d = (st && st.data && st.data.results) || (st && st.data) || {};
val = !!(d.is_connected || d.is_logged_in || d.connected);
} else if (gw === 'baileys') {
const mod = await import('./whatsappBot.mjs');
const s = mod.whatsappStatus;
val = !!(s && s.connection === 'open');
} else {
val = true;
}
} catch (e) { val = false; }
_connCache = { at: now, val: val };
return val;
}
module.exports = { sendText, sendImage, sendDocument, sendToStaff, isConnected, gateway };
File diff suppressed because it is too large. Load diff
+432
View File
@@ -0,0 +1,432 @@
const { logger } = require('../config/logger');
const { getSetting } = require('../config/settingsManager');
const db = require('../config/database');
const metaWAService = require('./metaWhatsappService');
const fonnteWAService = require('./fonnteWhatsappService');
const gowaWAService = require('./gowaWhatsappService');
/* ═══ Helper periode & tunggakan (port upstream 105fcab) ═══ */
const INDONESIAN_MONTH_NAMES = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
function getIndonesianMonthName(monthNumber) {
const m = parseInt(monthNumber, 10);
if (m >= 1 && m <= 12) {
return INDONESIAN_MONTH_NAMES[m - 1];
}
return String(monthNumber || '').trim();
}
/**
* Format periode bulan dan tahun ke dalam bahasa Indonesia, misal: "Oktober 2026"
* @param {string|number} month - Bulan (angka 1-12 atau string nama bulan atau "10/2026")
* @param {string|number} [year] - Tahun (misal 2026)
* @returns {string} Contoh: "Oktober 2026"
*/
function formatIndonesianPeriod(month, year) {
if (!month && !year) return '-';
let m = String(month || '').trim();
let y = String(year || '').trim();
if (!y && m.includes('/')) {
const parts = m.split('/');
m = parts[0].trim();
y = parts[1].trim();
} else if (!y && m.includes('-')) {
const parts = m.split('-');
if (parts[0].length === 4) {
y = parts[0].trim();
m = parts[1].trim();
} else {
m = parts[0].trim();
y = parts[1].trim();
}
}
// Jika terdapat koma (banyak bulan)
if (m.includes(',')) {
const monthItems = m.split(',').map(x => x.trim()).filter(Boolean);
const formattedMonths = monthItems.map(x => getIndonesianMonthName(x));
if (formattedMonths.length === 2) {
return `${formattedMonths[0]} & ${formattedMonths[1]}${y ? ' ' + y : ''}`;
} else if (formattedMonths.length > 2) {
const last = formattedMonths.pop();
return `${formattedMonths.join(', ')} & ${last}${y ? ' ' + y : ''}`;
}
return `${formattedMonths.join(', ')}${y ? ' ' + y : ''}`;
}
const mName = getIndonesianMonthName(m);
return y ? `${mName} ${y}` : mName;
}
/**
* Format daftar tagihan belum lunas (unpaid invoices) menjadi ringkasan yang jelas:
* - count: jumlah tagihan belum lunas
* - totalAmount: akumulasi nominal rupiah
* - totalAmountStr: format string rupiah (misal "220.000")
* - periodText: nama bulan dalam huruf (misal "September & Oktober 2026 (2 Bulan)")
* - breakdownText: rincian baris per bulan
* - isMultiple: boolean apakah menunggak > 1 bulan
*/
function formatUnpaidInvoicesSummary(unpaidInvoices) {
if (!unpaidInvoices || !Array.isArray(unpaidInvoices) || unpaidInvoices.length === 0) {
return {
count: 0,
totalAmount: 0,
totalAmountStr: '0',
periodText: '-',
breakdownText: '',
isMultiple: false
};
}
const formatter = new Intl.NumberFormat('id-ID');
const count = unpaidInvoices.length;
const totalAmount = unpaidInvoices.reduce((sum, inv) => sum + (Number(inv.amount) || 0), 0);
const totalAmountStr = formatter.format(totalAmount);
// Urutkan invoice berdasarkan period_year asc, period_month asc, id asc
const sorted = [...unpaidInvoices].sort((a, b) => {
const yA = Number(a.period_year) || 0;
const yB = Number(b.period_year) || 0;
if (yA !== yB) return yA - yB;
const mA = Number(a.period_month) || 0;
const mB = Number(b.period_month) || 0;
if (mA !== mB) return mA - mB;
return (Number(a.id) || 0) - (Number(b.id) || 0);
});
const periodStrings = sorted.map(inv => formatIndonesianPeriod(inv.period_month, inv.period_year));
let periodText = '';
if (periodStrings.length === 1) {
periodText = periodStrings[0];
} else if (periodStrings.length === 2) {
periodText = `${periodStrings[0]} & ${periodStrings[1]}`;
} else {
const last = periodStrings[periodStrings.length - 1];
periodText = `${periodStrings.slice(0, -1).join(', ')} & ${last}`;
}
const breakdownLines = sorted.map(inv => {
const pStr = formatIndonesianPeriod(inv.period_month, inv.period_year);
const amtStr = formatter.format(Number(inv.amount) || 0);
return `• *${pStr}:* Rp ${amtStr}${inv.id ? ` (Inv #${inv.id})` : ''}`;
});
return {
count,
totalAmount,
totalAmountStr,
periodText: count > 1 ? `${periodText} (${count} Bulan)` : periodText,
breakdownText: breakdownLines.join('\n'),
isMultiple: count > 1
};
}
/**
* Unified WhatsApp Gateway Service
* Menangani routing pengiriman pesan baik via Baileys (Unofficial Socket), Fonnte API, maupun Meta Cloud API (Official Meta).
*/
/**
* Kirim pesan WhatsApp universal
* @param {string} toPhone Nomor HP tujuan
* @param {string} messageText Teks pesan
* @param {object} options Opsi tambahan: { templateName, parameters, langCode, url, filename }
*/
async function sendWhatsAppMessage(toPhone, messageText, options = {}) {
const gatewayType = getSetting('wa_gateway_type', 'baileys'); // 'baileys', 'fonnte', or 'meta'
if (gatewayType === 'fonnte') {
// Mode FONNTE API GATEWAY (Cloud / Self-Hosted)
const res = await fonnteWAService.sendFonnteMessage(toPhone, messageText, options);
return res && res.success;
} else if (gatewayType === 'meta') {
// Mode META API RESMI
if (options.templateName) {
// Kirim via Meta Template Message
return await metaWAService.sendMetaTemplateMessage(
toPhone,
options.templateName,
options.langCode || 'id',
options.parameters || []
);
} else {
// Kirim via Meta Direct Text Message
return await metaWAService.sendMetaTextMessage(toPhone, messageText);
}
} else if (gatewayType === 'gowa') {
// Mode GOWA (go-whatsapp-web-multidevice)
const res = await gowaWAService.sendGowaMessage(toPhone, messageText, options);
try {
const phone = metaWAService.normalizePhone(toPhone);
let custName = 'Pelanggan'; let custId = null;
const cust = db.prepare('SELECT id, name FROM customers WHERE phone LIKE ? OR phone LIKE ?').get(`%${phone.slice(-8)}%`, `%${phone}%`);
if (cust) { custName = cust.name; custId = cust.id; }
db.prepare(`INSERT INTO wa_chat_messages (direction, gateway, sender_phone, recipient_phone, customer_id, customer_name, message_text, status) VALUES ('outbound','gowa','gowa_bot',?,?,?,?,'sent')`).run(phone, custId, custName, messageText);
} catch (e) {}
return res && res.success;
} else {
// Mode BAILEYS WEB (Default Socket)
const { sendWA, whatsappStatus } = await import('./whatsappBot.mjs');
if (!whatsappStatus || whatsappStatus.connection !== 'open') {
throw new Error('Bot WhatsApp (Baileys) belum terhubung / offline. Silakan scan QR Code di menu /admin/whatsapp.');
}
const ok = await sendWA(toPhone, messageText, options);
if (!ok) {
throw new Error('Gagal mengirim pesan via Baileys. Pastikan nomor HP tujuan terdaftar di WhatsApp.');
}
// Logging ke wa_chat_messages untuk Inbox / Live Chat
try {
const phone = metaWAService.normalizePhone(toPhone);
let custName = 'Pelanggan';
let custId = null;
const cust = db.prepare('SELECT id, name FROM customers WHERE phone LIKE ? OR phone LIKE ?').get(`%${phone.slice(-8)}%`, `%${phone}%`);
if (cust) {
custName = cust.name;
custId = cust.id;
}
db.prepare(`
INSERT INTO wa_chat_messages
(direction, gateway, sender_phone, recipient_phone, customer_id, customer_name, message_text, status)
VALUES ('outbound', 'baileys', 'baileys_bot', ?, ?, ?, ?, 'sent')
`).run(phone, custId, custName, messageText);
} catch (e) {}
return ok;
}
}
/**
* Ambil riwayat chat / percakapan dengan nomor tertentu untuk Live Chat
*/
function getChatHistory(phone, limit = 50) {
const normalized = metaWAService.normalizePhone(phone);
if (!normalized) return [];
const rows = db.prepare(`
SELECT * FROM wa_chat_messages
WHERE sender_phone LIKE ? OR recipient_phone LIKE ? OR sender_phone LIKE ? OR recipient_phone LIKE ?
ORDER BY id ASC
LIMIT ?
`).all(`%${normalized.slice(-8)}%`, `%${normalized.slice(-8)}%`, `%${normalized}%`, `%${normalized}%`, Number(limit) || 50);
return rows || [];
}
/**
* Ambil daftar percakapan terbaru (Inbox Live Chat)
*/
function getRecentConversations(limit = 30) {
const rows = db.prepare(`
SELECT
m.id,
m.direction,
m.gateway,
CASE WHEN m.direction = 'inbound' THEN m.sender_phone ELSE m.recipient_phone END as phone,
m.customer_id,
m.customer_name,
m.message_text,
m.status,
m.created_at
FROM wa_chat_messages m
INNER JOIN (
SELECT MAX(id) as max_id
FROM wa_chat_messages
GROUP BY CASE WHEN direction = 'inbound' THEN sender_phone ELSE recipient_phone END
) latest ON m.id = latest.max_id
ORDER BY m.id DESC
LIMIT ?
`).all(Number(limit) || 30);
return rows || [];
}
const DEFAULT_PAYMENT_SUCCESS_TEMPLATE =
`🧾 *BUKTI PEMBAYARAN RESMI (LUNAS)*
🏢 *{{company}}*
────────────────────────────
Yth. Pelanggan *{{nama}}*,
Terima kasih, pembayaran tagihan internet Anda telah kami terima dan diverifikasi.
📋 *Rincian Pembayaran:*
• *No. Invoice:* #INV-{{no_invoice}}
• *ID Pelanggan:* {{id_pelanggan}}
• *Paket Layanan:* {{paket}}
• *Periode:* {{periode}}
• *Waktu Bayar:* {{waktu}}
• *Metode Bayar:* {{metode}}
• *Diterima Oleh:* {{penerima}}
• *Total Bayar:* *Rp {{total}}*
• *Status:* *LUNAS ✅*
🌐 *Status Layanan:*
Layanan internet Anda saat ini dalam status *AKTIF* dan dapat digunakan dengan nyaman.
📄 *Unduh Invoice (PDF):*
{{link_pdf}}
────────────────────────────
🔗 *Cek Tagihan / Riwayat:*
{{link_portal}}
📞 *Bantuan & Layanan Pelanggan:*
WhatsApp: {{cs_phone}}
_Simpan pesan ini sebagai bukti pembayaran yang sah dari {{company}}._`;
function getIndonesianMonthName(monthNumber) {
const months = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
const idx = Number(monthNumber) - 1;
return months[idx] || String(monthNumber || '');
}
/**
* Format Payment Success WhatsApp Message
*/
function formatPaymentSuccessMessage({
customerName = '',
invoiceId = '',
customerUsername = '',
customerCode = '',
packageName = '',
periodMonth = '',
periodYear = '',
amount = 0,
paymentMethod = '',
paidAt = null,
companyName = '',
companyPhone = '',
portalUrl = '',
linkPdf = '',
receiver = '',
csPhone = '',
customTemplate = '',
remainingUnpaidInvoices = null,
customerId = null
}) {
let template = String(customTemplate || DEFAULT_PAYMENT_SUCCESS_TEMPLATE).trim();
if (!template) template = DEFAULT_PAYMENT_SUCCESS_TEMPLATE;
const periodText = formatIndonesianPeriod(periodMonth, periodYear) || '-';
const tz = getSetting('timezone', 'Asia/Jakarta');
let tzSuffix = 'WIB';
if (/Makassar|Ujung_Pandang|Bali|Pontianak|Banjarmasin|Manado|Mataram|Kupang/i.test(tz)) {
tzSuffix = 'WITA';
} else if (/Jayapura|Ambon|Papua|Maluku/i.test(tz)) {
tzSuffix = 'WIT';
} else if (!/Jakarta|Bangkok|Asia\/Jakarta/i.test(tz)) {
tzSuffix = tz;
}
let formattedTime = '';
try {
const { formatDateLocal } = require('../config/settingsManager');
formattedTime = `${formatDateLocal(paidAt || new Date())} ${tzSuffix}`;
} catch {
const now = new Date();
formattedTime = `${now.toLocaleDateString('id-ID')} ${now.toLocaleTimeString('id-ID')} ${tzSuffix}`;
}
const formattedAmount = Number(amount || 0).toLocaleString('id-ID');
// Sisa tunggakan lain (struk pelunasan lengkap) — port upstream 105fcab
let remainingList = remainingUnpaidInvoices;
if (remainingList === null && customerId) {
try {
const billingSvc = require('./billingService.js');
const allUnpaid = billingSvc.getUnpaidInvoicesByCustomerId(customerId) || [];
remainingList = allUnpaid.filter(inv => String(inv.id) !== String(invoiceId));
} catch (e) {}
}
let tunggakanInfo = '';
if (remainingList && Array.isArray(remainingList) && remainingList.length > 0) {
const summary = formatUnpaidInvoicesSummary(remainingList);
tunggakanInfo = `\n⚠️ *Perhatian Tunggakan Lain:*\nMasih ada *${summary.count} tagihan lain* belum lunas:\n${summary.breakdownText}\n💰 *Total Sisa Tunggakan:* *Rp ${summary.totalAmountStr}*\n_Mohon segera diselesaikan agar layanan tetap lancar._\n`;
}
const _invIdMatch = String(invoiceId || '').match(/\d+/);
const pdfLink = (portalUrl && _invIdMatch) ? (String(portalUrl).replace(/\/$/, '') + '/invoice/' + _invIdMatch[0] + '/pdf') : (portalUrl || '-');
let rendered = template
.replace(/{{nama}}/gi, customerName || 'Pelanggan')
.replace(/{{no_invoice}}/gi, String(invoiceId || '-'))
.replace(/{{invoice_id}}/gi, String(invoiceId || '-'))
.replace(/{{username}}/gi, customerUsername || '-')
.replace(/{{id_pelanggan}}/gi, customerCode || customerUsername || '-')
.replace(/{{penerima}}/gi, receiver || '-')
.replace(/{{diterima_oleh}}/gi, receiver || '-')
.replace(/{{cs_phone}}/gi, csPhone || companyPhone || '-')
.replace(/{{paket}}/gi, packageName || '-')
.replace(/{{periode}}/gi, periodText)
.replace(/{{total}}/gi, formattedAmount)
.replace(/{{metode}}/gi, paymentMethod || 'Admin / Kasir')
.replace(/{{waktu}}/gi, formattedTime)
.replace(/{{tanggal}}/gi, formattedTime)
.replace(/{{company}}/gi, companyName || 'OPEN-ISP')
.replace(/{{company_phone}}/gi, companyPhone || '-')
.replace(/{{link_portal}}/gi, portalUrl || '-')
.replace(/{{link}}/gi, portalUrl || '-')
.replace(/{{link_pdf}}/gi, linkPdf || pdfLink)
.replace(/{{pdf}}/gi, linkPdf || pdfLink)
.replace(/{{pppoe}}/gi, customerUsername || '-')
.replace(/{{tunggakan_info}}/gi, tunggakanInfo);
// Sisipkan info tunggakan lain bila template tidak punya placeholder-nya
if (tunggakanInfo && !template.includes('{{tunggakan_info}}')) {
if (rendered.includes('───')) {
const idx = rendered.lastIndexOf('───');
rendered = rendered.slice(0, idx) + `${tunggakanInfo}\n` + rendered.slice(idx);
} else {
rendered += `\n${tunggakanInfo}`;
}
}
// Process spintax {A|B|C} only if pipe is present
rendered = rendered.replace(/\{([^{}|]+(?:\|[^{}|]+)+)\}/g, (match, choices) => {
const arr = choices.split('|');
return arr[Math.floor(Math.random() * arr.length)].trim();
});
return rendered;
}
// Jeda "mengetik manusiawi": meniru tempo menulis (proporsional panjang pesan).
// Dipakai sebelum kirim pada pengiriman massal (broadcast/cron) agar tidak terlihat bot.
function humanTypingDelay(messageText, opts = {}) {
const len = String(messageText || '').length;
const perChar = Number(opts.perChar) || 30;
const min = Number(opts.min) || 700;
const max = Number(opts.max) || 3500;
const ms = Math.min(Math.max(len * perChar, min), max);
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = {
formatUnpaidInvoicesSummary,
formatIndonesianPeriod,
getIndonesianMonthName,
sendWhatsAppMessage,
humanTypingDelay,
sendWA: sendWhatsAppMessage, // Alias untuk kompatibilitas fungsi lama
getChatHistory,
getRecentConversations,
getIndonesianMonthName,
formatPaymentSuccessMessage,
DEFAULT_PAYMENT_SUCCESS_TEMPLATE
};