'Giro Umum', 'simanja' => 'Tabungan SIMANJA', 'simpeda' => 'Tabungan SIMPEDA']; /** Excel ROUNDDOWN(x, 0) on 15 significant digits (same as Number(x.toPrecision(15)) in JS). */ function engine_rounddown(float $x): float { $v = (float)sprintf('%.14e', $x); return $v < 0 ? ceil($v) : floor($v); } /** Tiers [{max: float|null, rate}], ascending; first tier with n <= max wins, null max = "di atas". */ function engine_tier_rate(?array $tiers, float $n): float { foreach ($tiers ?? [] as $t) { if (!isset($t['max']) || $n <= $t['max']) { return (float)($t['rate'] ?? 0); } } return 0.0; } /** Cashback sharing [{min, nasabah}]: highest min that n reaches wins (COB!I34:I35). */ function engine_share(array $params, float $n, int $tenor): array { if (!in_array($tenor, $params['cashbackTenors'] ?? [], true)) { return ['share' => null, 'reason' => 'tenor']; } $tiers = $params['cashbackTiers'] ?? []; usort($tiers, fn($a, $b) => $b['min'] <=> $a['min']); foreach ($tiers as $t) { if ($n >= $t['min']) { return ['share' => (float)$t['nasabah'], 'reason' => null]; } } return ['share' => null, 'reason' => 'nominal']; } function engine_min_cashback_nominal(array $params): float { $mins = array_column($params['cashbackTiers'] ?? [], 'min'); return $mins ? (float)min($mins) : 0.0; } function engine_deposito_rate(array $params, float $n, int $tenor): float { if ($n < $params['settings']['deposito_min']) { // COB!K14: F4<1000000 -> 0 return 0.0; } return engine_tier_rate($params['depositoRates'][$tenor] ?? null, $n); } function engine_calculate(array $params, float $nominal, int $tenor, string $customerType): array { $p = $params['settings']; $n = max(0.0, $nominal); $t = $tenor; $customerType = $customerType === 'badan' ? 'badan' : 'perorangan'; $year = $p['hari_setahun']; $month = $p['hari_sebulan']; // Pengelolaan dana (COB!F23:F38) $gwm = $n * $p['gwm']; $kas = $n * $p['kas']; $managed = $n - ($gwm + $kas); $puabBase = $managed * $p['porsi_puab']; $puabIncome = $puabBase * $p['puab'] * ($month / $year) * $t; $creditBase = $managed * $p['porsi_kredit']; $creditIncome = $creditBase * $p['kredit'] * ($t / 12); $income = $puabIncome + $creditIncome; $sh = engine_share($params, $n, $t); $eligible = $sh['share'] !== null; $share = $eligible ? $sh['share'] : 0.0; // Biaya & spread per produk (COB!H22:R37) $lps = min($n, $p['lps_batas']) * $p['premi_lps']; $ohc = $puabIncome * $p['ohc']; $products = []; foreach (ENGINE_PRODUCTS as $key => $name) { $rate = engine_tier_rate($params['productRates'][$key] ?? null, $n); $bunga = engine_rounddown((($n * $rate * $month) / $year) * $t); $program = $key === 'simanja' ? $p['biaya_program'] : 0.0; $cost = $bunga + $program + $ohc + $lps; $spread = $income - $cost; $bungaTax = $bunga * $p['pajak_tabungan']; $products[$key] = [ 'key' => $key, 'name' => $name, 'rate' => $rate, 'bungaGross' => $bunga, 'bungaTax' => $bungaTax, 'bungaNet' => $bunga - $bungaTax, 'program' => $program, 'ohc' => $ohc, 'lps' => $lps, 'cost' => $cost, 'spread' => $spread, 'cashbackGross' => $spread * $share, 'costOfFund' => $managed > 0 ? ($cost + $spread * $share) / $managed : 0.0, ]; } // Cashback = rata-rata 3 produk (COB!H7:H10) $gross = $eligible ? ($products['giro']['cashbackGross'] + $products['simanja']['cashbackGross'] + $products['simpeda']['cashbackGross']) / 3 : 0.0; $negative = $gross < 0; if ($negative) { $gross = 0.0; } $taxRate = $customerType === 'badan' ? $p['pajak_cashback_badan'] : $p['pajak_cashback_perorangan']; $tax = $gross * $taxRate; $net = $gross - $tax; $equiv = $n > 0 && $t > 0 ? ($net / $n) * ($year / ($month * $t)) : 0.0; // Deposito pembanding (COB!K14:L17) $dRate = engine_deposito_rate($params, $n, $t); $dGross = engine_rounddown((($n * $dRate * $month) / $year) * $t); $dTax = $dGross * $p['pajak_deposito']; $dNet = $dGross - $dTax; $deposito = ['rate' => $dRate, 'gross' => $dGross, 'tax' => $dTax, 'net' => $dNet, 'monthly' => $t ? $dNet / $t : 0.0]; // Perbandingan (CALC!A10:F13) $comparison = []; foreach (ENGINE_PRODUCTS as $key => $name) { $pr = $products[$key]; $total = $pr['bungaNet'] + $net; $comparison[] = [ 'key' => $key, 'name' => $name, 'rate' => $pr['rate'], 'bungaNet' => $pr['bungaNet'], 'cashbackNet' => $net, 'total' => $total, 'totalRate' => $pr['rate'] + $equiv, 'diff' => $total - $dNet, ]; } $best = array_reduce($comparison, fn($a, $b) => $a === null || $b['total'] > $a['total'] ? $b : $a); // Pinalti pencairan sebelum jatuh tempo (CALC!A17:B22) $penalty = ['refund' => $net, 'tax' => $gross * $taxRate, 'fee' => $net * $p['pinalti']]; $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' => engine_min_cashback_nominal($params), 'share' => $share, 'shareBank' => $eligible ? 1 - $share : 0.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.0, 'equiv' => $equiv], 'deposito' => $deposito, 'comparison' => $comparison, 'best' => $best, 'penalty' => $penalty, ]; } /** * What a visitor without an admin session may see: the customer-facing result * only. Cost structure, spread, sharing tiers and the deposito comparison stay * on the server. */ function engine_public_result(array $r, array $params): array { return [ 'input' => $r['input'], 'eligible' => $r['eligible'], 'reason' => $r['reason'], 'minNominal' => $r['minNominal'], 'share' => $r['share'], 'cashback' => $r['cashback'], 'comparison' => array_map(fn($c) => array_diff_key($c, ['diff' => 1]), $r['comparison']), 'penalty' => $r['penalty'] + ['rate' => $params['settings']['pinalti']], ]; } /** * Model for the public in-browser engine (assets/js/engine-public.js), so the * calculator answers instantly and offline. The bank's cost structure is folded * into one margin per tenor: * * pendapatan PUAB − OHC + pendapatan kredit = nominal × margin[tenor] * margin = (1 − GWM − kas) × (porsi PUAB × rate PUAB × hari sebulan/hari setahun × tenor × (1 − OHC) * + porsi kredit × bunga kredit × tenor/12) * * GWM, kas, porsi PUAB/kredit, rate PUAB, bunga kredit, OHC and the deposito * rates are not sent. Everything else is customer-facing (product rates, taxes, * penalty, LPS rules) or, like the sharing tiers, can be read off the results. */ function engine_client_model(array $params): array { $p = $params['settings']; $year = $p['hari_setahun']; $month = $p['hari_sebulan']; $margin = []; foreach ($params['tenors'] as $t) { $margin[(string)$t] = (1 - ($p['gwm'] + $p['kas'])) * ($p['porsi_puab'] * $p['puab'] * ($month / $year) * $t * (1 - $p['ohc']) + $p['porsi_kredit'] * $p['kredit'] * ($t / 12)); } return [ 'tenors' => $params['tenors'], 'cashbackTenors' => $params['cashbackTenors'], 'minNominal' => engine_min_cashback_nominal($params), 'days' => ['month' => $month, 'year' => $year], 'margin' => $margin, 'programAvg' => $p['biaya_program'] / 3, // SIMANJA programme fee, averaged over the 3 products 'lps' => ['rate' => $p['premi_lps'], 'cap' => $p['lps_batas']], 'productRates' => $params['productRates'], 'cashbackTiers' => $params['cashbackTiers'], 'tax' => ['bunga' => $p['pajak_tabungan'], 'perorangan' => $p['pajak_cashback_perorangan'], 'badan' => $p['pajak_cashback_badan']], 'pinalti' => $p['pinalti'], 'paramsAt' => $params['updatedAt'], 'generatedAt' => date('c'), ]; }