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

+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
};