Files
open-isp/services/billingService.js
T
oxpoe eaf05f2811 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
2026-10-09 05:51:21 +07:00

815 lines
34 KiB
JavaScript

/**
* 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
};