asd
This commit is contained in:
commit
3e4a056b14
67 files changed
+13926
No files matched your search
@@ -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'),
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user