Files
calcserubi/includes/engine.php
T
2026-10-20 13:16:38 +09:00

216 lines
8.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/*
* SERUBI cashback engine, server side. Line-by-line port of assets/js/engine.js
* (sheet "COB" and "CALCULATOR CASHBACK SERUBI" of the reference workbook), so
* the public calculator gets results without receiving the bank's internal
* parameters. Both engines are validated against tests/excel-fixtures.json.
*/
const ENGINE_PRODUCTS = ['giro' => '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'),
];
}