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