Files

834 lines
35 KiB
JavaScript

/**
* 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', 'gowa');
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.notifyAdmin(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 };