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