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
163 lines
6.1 KiB
JavaScript
163 lines
6.1 KiB
JavaScript
'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
|
|
};
|