This commit is contained in:
DIGIDEV\corer committed 2026-10-20 13:16:38 +09:00
commit 3e4a056b14
67 files changed
+13926

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
/*
* SERUBI public engine: the customer-facing result, calculated in the browser
* from the folded model of engine_client_model() (includes/engine.php). The
* page never receives the bank's internal parameters, only one margin per
* tenor. Same numbers as assets/js/engine.js and includes/engine.php, checked
* against the reference workbook by tests/validate_public.js.
* Works in the browser (window.SerubiPublicEngine) and in Node (module.exports).
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.SerubiPublicEngine = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
var PRODUCTS = [
{ key: 'giro', name: 'Giro Umum' },
{ key: 'simanja', name: 'Tabungan SIMANJA' },
{ key: 'simpeda', name: 'Tabungan SIMPEDA' }
];
// Excel ROUNDDOWN(x, 0) on 15 significant digits (see engine.js).
function rounddown(x) {
var v = Number(x.toPrecision(15));
return v < 0 ? Math.ceil(v) : Math.floor(v);
}
function tierRate(tiers, n) {
if (!tiers || !tiers.length) return 0;
for (var i = 0; i < tiers.length; i++) {
var t = tiers[i];
if (t.max === null || t.max === undefined || n <= t.max) return Number(t.rate) || 0;
}
return 0;
}
function shareFor(m, n, tenor) {
if ((m.cashbackTenors || []).indexOf(tenor) === -1) return { share: null, reason: 'tenor' };
var tiers = (m.cashbackTiers || []).slice().sort(function (a, b) { return b.min - a.min; });
for (var i = 0; i < tiers.length; i++) {
if (n >= tiers[i].min) return { share: Number(tiers[i].nasabah), reason: null };
}
return { share: null, reason: 'nominal' };
}
function calculate(m, input) {
var n = Math.max(0, Number(input.nominal) || 0);
var t = Number(input.tenor) || 0;
var customerType = input.customerType === 'badan' ? 'badan' : 'perorangan';
var year = m.days.year, month = m.days.month;
var sh = shareFor(m, n, t);
var eligible = sh.share !== null;
var share = eligible ? sh.share : 0;
// Bunga per produk (COB!J24/N24/R24), identical to the full engine
var bunga = {}, bungaSum = 0;
PRODUCTS.forEach(function (prod) {
var rate = tierRate((m.productRates || {})[prod.key], n);
var b = rounddown(((n * rate * month) / year) * t);
bunga[prod.key] = { rate: rate, gross: b };
bungaSum += b;
});
// Rata-rata spread 3 produk = margin × nominal − rata-rata bunga − biaya program/3 − premi LPS
var lps = Math.min(n, m.lps.cap) * m.lps.rate;
var avgSpread = n * (m.margin[t] || 0) - bungaSum / 3 - m.programAvg - lps;
var gross = eligible ? avgSpread * share : 0;
var negative = gross < 0;
if (negative) gross = 0;
var taxRate = m.tax[customerType];
var tax = gross * taxRate;
var net = gross - tax;
var equiv = n > 0 && t > 0 ? (net / n) * (year / (month * t)) : 0;
var comparison = PRODUCTS.map(function (prod) {
var b = bunga[prod.key];
var bungaNet = b.gross - b.gross * m.tax.bunga;
var total = bungaNet + net;
return { key: prod.key, name: prod.name, rate: b.rate, bungaNet: bungaNet, cashbackNet: net, total: total, totalRate: b.rate + equiv };
});
var penalty = { refund: net, tax: gross * taxRate, fee: net * m.pinalti, rate: m.pinalti };
penalty.total = penalty.refund + penalty.tax + penalty.fee;
var mins = (m.cashbackTiers || []).map(function (x) { return x.min; });
return {
input: { nominal: n, tenor: t, customerType: customerType },
eligible: eligible && !negative,
reason: negative ? 'spread' : sh.reason,
minNominal: mins.length ? Math.min.apply(null, mins) : 0,
share: share,
cashback: { gross: gross, taxRate: taxRate, tax: tax, net: net, monthly: t ? net / t : 0, equiv: equiv },
comparison: comparison,
penalty: penalty
};
}
return { calculate: calculate, PRODUCTS: PRODUCTS };
});