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

+215
View File
@@ -0,0 +1,215 @@
<?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'),
];
}