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

+157
View File
@@ -0,0 +1,157 @@
/*
* SERUBI cashback engine.
* Faithful port of sheet "COB" and "CALCULATOR CASHBACK SERUBI" from
* CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx. Cell references are
* noted next to each step so the numbers can be audited against the workbook.
* Works in the browser (window.SerubiEngine) and in Node (module.exports).
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.SerubiEngine = 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) works on 15 significant digits; mirror that so
// values like 203424.99999999997 do not lose a rupiah.
function rounddown(x) {
var v = Number(x.toPrecision(15));
return v < 0 ? Math.ceil(v) : Math.floor(v);
}
// Tiers are [{max: number|null, rate}], ascending; first tier with n <= max
// wins, a null max means "di atas" (everything above the previous tier).
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;
}
// Cashback sharing: [{min, nasabah}] — highest min that n reaches wins (COB!I34:I35).
function shareFor(params, n, tenor) {
if ((params.cashbackTenors || []).indexOf(tenor) === -1) return { share: null, reason: 'tenor' };
var tiers = (params.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 minCashbackNominal(params) {
var mins = (params.cashbackTiers || []).map(function (t) { return t.min; });
return mins.length ? Math.min.apply(null, mins) : 0;
}
function depositoRate(params, n, tenor) {
if (n < params.settings.deposito_min) return 0; // COB!K14: F4<1000000 -> 0
return tierRate((params.depositoRates || {})[tenor], n);
}
function calculate(params, input) {
var p = params.settings;
var n = Math.max(0, Number(input.nominal) || 0);
var t = Number(input.tenor) || 0;
var customerType = input.customerType === 'badan' ? 'badan' : 'perorangan';
var year = p.hari_setahun, month = p.hari_sebulan;
// Pengelolaan dana (COB!F23:F38)
var gwm = n * p.gwm; // F24
var kas = n * p.kas; // F25
var managed = n - (gwm + kas); // F26
var puabBase = managed * p.porsi_puab; // F28
var puabIncome = puabBase * p.puab * (month / year) * t; // F31 (A)
var creditBase = managed * p.porsi_kredit; // F34
var creditIncome = creditBase * p.kredit * (t / 12); // F36 (B)
var income = puabIncome + creditIncome; // F38
var sh = shareFor(params, n, t);
var eligible = sh.share !== null;
var share = eligible ? sh.share : 0;
// Biaya & spread per produk (COB!H22:R37)
var lps = Math.min(n, p.lps_batas) * p.premi_lps; // J27 (charged once, not prorated)
var ohc = puabIncome * p.ohc; // J26 / N25 / R25
var products = {};
PRODUCTS.forEach(function (prod) {
var rate = tierRate((params.productRates || {})[prod.key], n); // I24 / M24 / Q24
var bunga = rounddown(((n * rate * month) / year) * t); // J24 / N24 / R24
var program = prod.key === 'simanja' ? p.biaya_program : 0; // J25 (SIMANJA only)
var cost = bunga + program + ohc + lps; // J28 / N27 / R27
var spread = income - cost; // J29 / N28 / R28
var bungaTax = bunga * p.pajak_tabungan; // F7 / F10 / F13
products[prod.key] = {
key: prod.key, name: prod.name, rate: rate,
bungaGross: bunga, bungaTax: bungaTax, bungaNet: bunga - bungaTax,
program: program, ohc: ohc, lps: lps, cost: cost, spread: spread,
cashbackGross: spread * share, // J35 / N34 / R34
costOfFund: managed > 0 ? (cost + spread * share) / managed : 0 // J30 / N29 / R29
};
});
// Cashback = rata-rata 3 produk (COB!H7:H10)
var gross = eligible ? (products.giro.cashbackGross + products.simanja.cashbackGross + products.simpeda.cashbackGross) / 3 : 0;
var negative = gross < 0;
if (negative) gross = 0;
var taxRate = customerType === 'badan' ? p.pajak_cashback_badan : p.pajak_cashback_perorangan;
var tax = gross * taxRate; // H9
var net = gross - tax; // H10
var equiv = n > 0 && t > 0 ? (net / n) * (year / (month * t)) : 0; // CALC!D10
// Deposito pembanding (COB!K14:L17)
var dRate = depositoRate(params, n, t);
var dGross = rounddown(((n * dRate * month) / year) * t);
var dTax = dGross * p.pajak_deposito;
var dNet = dGross - dTax;
var deposito = { rate: dRate, gross: dGross, tax: dTax, net: dNet, monthly: t ? dNet / t : 0 };
// Perbandingan (CALC!A10:F13)
var comparison = PRODUCTS.map(function (prod) {
var pr = products[prod.key];
var total = pr.bungaNet + net;
return {
key: prod.key, name: prod.name, rate: pr.rate, bungaNet: pr.bungaNet,
cashbackNet: net, total: total, totalRate: pr.rate + equiv, diff: total - dNet
};
});
// Pinalti pencairan sebelum jatuh tempo (CALC!A17:B22)
var penalty = {
refund: net,
tax: gross * taxRate,
fee: net * p.pinalti,
total: 0
};
penalty.total = penalty.refund + penalty.tax + penalty.fee;
return {
input: { nominal: n, tenor: t, customerType: customerType },
eligible: eligible && !negative,
reason: negative ? 'spread' : sh.reason,
minNominal: minCashbackNominal(params),
share: share, shareBank: eligible ? 1 - share : 0,
fund: {
gwm: gwm, kas: kas, managed: managed, puabBase: puabBase, puabIncome: puabIncome,
creditBase: creditBase, creditIncome: creditIncome, income: income
},
products: products,
cashback: { gross: gross, taxRate: taxRate, tax: tax, net: net, monthly: t ? net / t : 0, equiv: equiv },
deposito: deposito,
comparison: comparison,
best: comparison.reduce(function (a, b) { return b.total > a.total ? b : a; }),
penalty: penalty
};
}
return {
calculate: calculate, tierRate: tierRate, depositoRate: depositoRate,
rounddown: rounddown, PRODUCTS: PRODUCTS
};
});