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

File diff suppressed because it is too large. Load diff
+71
View File
@@ -0,0 +1,71 @@
# Recalculates the official branch workbook with Microsoft Excel for a grid of
# nominal x tenor inputs and writes the results to tests/excel-fixtures.json.
# Usage: powershell -ExecutionPolicy Bypass -File tests\export-excel-fixtures.ps1 [-Workbook <path>]
param(
[string]$Workbook = "$env:USERPROFILE\Downloads\CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx",
[string]$Out = (Join-Path $PSScriptRoot 'excel-fixtures.json')
)
$ErrorActionPreference = 'Stop'
# Work on a temp copy so the original file is never touched.
$tmp = Join-Path $env:TEMP ("serubi-" + [guid]::NewGuid().ToString() + ".xlsx")
Copy-Item -LiteralPath $Workbook -Destination $tmp
$nominals = @(100000, 500000, 999999, 1000000, 10000000, 49999999, 50000000, 55000000, 99999999,
100000000, 100000001, 150000000, 250000000, 250000001, 300000000, 499999999, 500000000,
500000001, 750000000, 999999999, 1000000000, 1000000001, 1500000000, 1999999999,
2000000000, 2000000001, 5000000000, 10000000000, 50000000000, 50000000001, 100000000000)
$tenors = @(1, 3, 6, 12, 24)
function V($cell) {
$v = $cell.Value2
if ($v -is [int] -and $v -lt -2000000000) { return $null } # COM error codes (#VALUE!, ...)
if ($cell.Text -like '#*') { return $null }
if ($v -is [string] -and $v -eq '') { return $null }
return $v
}
$xl = New-Object -ComObject Excel.Application
$xl.Visible = $false
$xl.DisplayAlerts = $false
try {
$wb = $xl.Workbooks.Open($tmp)
$calc = $wb.Worksheets.Item('CALCULATOR CASHBACK SERUBI')
$cob = $wb.Worksheets.Item('COB')
$rows = @()
foreach ($t in $tenors) {
foreach ($n in $nominals) {
$calc.Range('A6').Value2 = [double]$n
$calc.Range('B6').Value2 = [double]$t
$xl.Calculate()
$rows += [ordered]@{
nominal = $n; tenor = $t
cashbackGross = V $calc.Range('C6'); cashbackNet = V $calc.Range('D6')
giroNet = V $calc.Range('E6'); simanjaNet = V $calc.Range('F6'); simpedaNet = V $calc.Range('G6'); depositoNet = V $calc.Range('H6')
totalGiro = V $calc.Range('B10'); totalSimanja = V $calc.Range('B11'); totalSimpeda = V $calc.Range('B12'); totalDeposito = V $calc.Range('B13')
rateGiro = V $calc.Range('C10'); rateSimanja = V $calc.Range('C11'); rateSimpeda = V $calc.Range('C12')
equiv = V $calc.Range('D10')
totalRateGiro = V $calc.Range('E10'); totalRateSimanja = V $calc.Range('E11'); totalRateSimpeda = V $calc.Range('E12')
rateDeposito = V $calc.Range('F10')
penaltyRefund = V $calc.Range('B19'); penaltyTax = V $calc.Range('B20'); penaltyFee = V $calc.Range('B21'); penaltyTotal = V $calc.Range('B22')
shareNasabah = V $cob.Range('I35'); shareBank = V $cob.Range('I34')
income = V $cob.Range('F38'); puabIncome = V $cob.Range('F31'); creditIncome = V $cob.Range('F36')
spreadSimanja = V $cob.Range('J29'); spreadSimpeda = V $cob.Range('N28'); spreadGiro = V $cob.Range('R28')
depositoGross = V $cob.Range('L14')
}
}
}
$wb.Close($false)
} finally {
$xl.Quit()
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($xl) | Out-Null
Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue
}
$payload = [ordered]@{
source = (Split-Path $Workbook -Leaf)
generatedAt = (Get-Date).ToString('s')
cases = $rows
}
$payload | ConvertTo-Json -Depth 5 | Out-File -FilePath $Out -Encoding utf8
Write-Host ("Wrote {0} cases to {1}" -f $rows.Count, $Out)
+29
View File
@@ -0,0 +1,29 @@
// The calculation from the original single-file app ("CALCULATOR CASHBACK SERUBI -
// UPDATE BUNGA NETO.html"), copied verbatim and wrapped to return the figures it
// displayed. Used only by `node tests/validate.js --legacy` for the comparison report.
'use strict';
const P={gwm:.09,kas:.02,puab:.051,kredit:.09,ohc:.0442,lps:.002,pajak:.20};
const depo={1:[[1e8,.03],[2.5e8,.0305],[5e8,.031],[1e9,.0325],[Infinity,.034]],3:[[1e8,.031],[2.5e8,.0315],[5e8,.032],[1e9,.0335],[Infinity,.035]],6:[[1e8,.032],[2.5e8,.0325],[5e8,.033],[1e9,.0345],[Infinity,.035]],12:[[1e8,.033],[2.5e8,.0335],[5e8,.034],[1e9,.035],[Infinity,.035]],24:[[1e8,.034],[2.5e8,.0345],[5e8,.035],[1e9,.035],[Infinity,.035]]};
function productRate(product,n){if(product==='simanja'){if(n<=1e5)return 0;if(n<=1e8)return .0075;if(n<=5e8)return .01;if(n<=1e9)return .02;return .025}if(product==='simpeda'){if(n<=1e5)return 0;if(n<=1e8)return .005;if(n<=5e8)return .0075;if(n<=1e9)return .015;return .02}if(product==='giro'){if(n<=5e7)return 0;if(n<=5e8)return .005;if(n<=1e9)return .005;if(n<=5e10)return .005;return .0075}}
function depRate(n,t){let a=depo[t]||[];if(n<1e6)return 0;for(const [lim,r] of a)if(n<=lim)return r;return 0}
function cashbackShare(n,t){if(![3,6,12,24].includes(t))return 0;if(n>=2e9)return .30;if(n>=250000001)return .45;if(n>=5e7)return .50;return 0}
module.exports = function legacy(n, t) {
const managed=n*(1-P.gwm-P.kas); const puabIncome=(managed*.10*P.puab)*(30/365)*t; const creditIncome=(managed*.90*P.kredit)*(t/12); const income=puabIncome+creditIncome;
function cost(rate){const bunga=Math.floor(((n*rate*30)/365)*t);const ohc=puabIncome*P.ohc;const lps=Math.min(n,2e9)*P.lps;return {bunga,cost:bunga+ohc+lps,spread:income-(bunga+ohc+lps)}}
const rates={giro:productRate('giro',n),simanja:productRate('simanja',n),simpeda:productRate('simpeda',n)};const avgSpread=(cost(rates.giro).spread+cost(rates.simanja).spread+cost(rates.simpeda).spread)/3;const share=cashbackShare(n,t);const gross=avgSpread*share;const net=gross*(1-P.pajak);const eq=(t>0?net/n*(365/(30*t)):0);
const ok = share > 0; // Excel shows blank/#VALUE! where the old app showed Rp0
const bn = k => cost(rates[k]).bunga*(1-P.pajak);
const dr=depRate(n,t),dg=Math.floor(((n*dr*30)/365)*t),dn=dg*(1-P.pajak);
return {
cashbackGross: ok ? gross : null, cashbackNet: ok ? net : null,
giroNet: bn('giro'), simanjaNet: bn('simanja'), simpedaNet: bn('simpeda'),
depositoNet: dg, // shown in the "Bunga Neto" column of the DEPOSITO row
totalDeposito: dn,
totalGiro: ok ? bn('giro') + net : null, totalSimanja: ok ? bn('simanja') + net : null, totalSimpeda: ok ? bn('simpeda') + net : null,
rateGiro: rates.giro, rateSimanja: rates.simanja, rateSimpeda: rates.simpeda, rateDeposito: dr,
equiv: ok ? eq : null,
totalRateGiro: ok ? rates.giro + eq : null, totalRateSimanja: ok ? rates.simanja + eq : null, totalRateSimpeda: ok ? rates.simpeda + eq : null,
penaltyRefund: null, penaltyTax: null, penaltyFee: null, penaltyTotal: null // not implemented in the old app
};
};
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
/*
* Validates assets/js/engine.js against values recalculated by Microsoft Excel
* from the official workbook (tests/excel-fixtures.json, produced by
* tests/export-excel-fixtures.ps1), using the parameters stored in MySQL.
*
* node tests/validate.js engine (DB params) vs Excel
* node tests/validate.js --legacy also measure the original single-file HTML app
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const Engine = require('../assets/js/engine.js');
const root = path.join(__dirname, '..');
const fixtures = JSON.parse(fs.readFileSync(path.join(__dirname, 'excel-fixtures.json'), 'utf8').replace(/^\uFEFF/, ''));
const php = process.env.PHP_BIN || 'C:/laragon/bin/php/php-8.1.10-Win32-vs16-x64/php.exe';
const params = JSON.parse(execFileSync(php, ['-d', 'display_startup_errors=0', path.join(root, 'database/migrate.php'), '--dump-params'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }));
// Rupiah amounts must match to the rupiah after rounding; rates to 1e-9.
const close = (a, b, kind) => kind === 'rate' ? Math.abs(a - b) < 1e-9 : Math.round(a) === Math.round(b) && Math.abs(a - b) < 0.01;
function engineView(r) {
const by = Object.fromEntries(r.comparison.map(c => [c.key, c]));
const ok = r.eligible;
return {
cashbackGross: ok ? r.cashback.gross : null, cashbackNet: ok ? r.cashback.net : null,
giroNet: r.products.giro.bungaNet, simanjaNet: r.products.simanja.bungaNet, simpedaNet: r.products.simpeda.bungaNet,
depositoNet: r.deposito.net, depositoGross: r.deposito.gross, rateDeposito: r.deposito.rate,
totalGiro: ok ? by.giro.total : null, totalSimanja: ok ? by.simanja.total : null, totalSimpeda: ok ? by.simpeda.total : null,
totalDeposito: r.deposito.net,
rateGiro: r.products.giro.rate, rateSimanja: r.products.simanja.rate, rateSimpeda: r.products.simpeda.rate,
equiv: ok ? r.cashback.equiv : null,
totalRateGiro: ok ? by.giro.totalRate : null, totalRateSimanja: ok ? by.simanja.totalRate : null, totalRateSimpeda: ok ? by.simpeda.totalRate : null,
penaltyRefund: ok ? r.penalty.refund : null, penaltyTax: ok ? r.penalty.tax : null, penaltyFee: ok ? r.penalty.fee : null, penaltyTotal: ok ? r.penalty.total : null,
shareNasabah: ok ? r.share : null, shareBank: ok ? r.shareBank : null,
income: r.fund.income, puabIncome: r.fund.puabIncome, creditIncome: r.fund.creditIncome,
spreadSimanja: r.products.simanja.spread, spreadSimpeda: r.products.simpeda.spread, spreadGiro: r.products.giro.spread
};
}
const RATE_FIELDS = new Set(['equiv', 'rateGiro', 'rateSimanja', 'rateSimpeda', 'rateDeposito', 'totalRateGiro', 'totalRateSimanja', 'totalRateSimpeda', 'shareNasabah', 'shareBank']);
function compare(label, viewFn) {
let checks = 0; const failures = [];
for (const c of fixtures.cases) {
const got = viewFn(c);
for (const [field, expected] of Object.entries(c)) {
if (field === 'nominal' || field === 'tenor' || !(field in got)) continue;
checks++;
const actual = got[field];
const kind = RATE_FIELDS.has(field) ? 'rate' : 'idr';
const pass = expected === null ? actual === null : actual !== null && close(actual, expected, kind);
if (!pass) failures.push({ nominal: c.nominal, tenor: c.tenor, field, excel: expected, app: actual });
}
}
console.log(`\n${label}: ${checks - failures.length}/${checks} checks match Excel over ${fixtures.cases.length} cases`);
return failures;
}
const failures = compare('Engine (parameter dari MySQL)', c =>
engineView(Engine.calculate(params, { nominal: c.nominal, tenor: c.tenor, customerType: 'perorangan' })));
for (const f of failures.slice(0, 20)) console.log(' MISMATCH', JSON.stringify(f));
if (process.argv.includes('--legacy')) {
const legacy = require('./legacy-original.js');
const lf = compare('Aplikasi HTML lama', c => legacy(c.nominal, c.tenor));
const byField = {};
for (const f of lf) (byField[f.field] = byField[f.field] || []).push(f);
for (const [field, list] of Object.entries(byField)) {
const ex = list[0];
console.log(` ${field}: ${list.length} mismatch, contoh ${ex.nominal}/${ex.tenor} bln -> Excel ${ex.excel} vs lama ${ex.app}`);
}
}
process.exitCode = failures.length ? 1 : 0;
+70
View File
@@ -0,0 +1,70 @@
<?php
// Validates the server-side engine (includes/engine.php) against the values
// recalculated by Microsoft Excel (tests/excel-fixtures.json), using the
// parameters stored in MySQL. Same checks and tolerances as tests/validate.js.
// php tests/validate_engine.php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require __DIR__ . '/../includes/params.php';
require __DIR__ . '/../includes/engine.php';
$fixtures = json_decode(preg_replace('/^\xEF\xBB\xBF/', '', (string)file_get_contents(__DIR__ . '/excel-fixtures.json')), true);
$params = load_params_db();
$rateFields = ['equiv', 'rateGiro', 'rateSimanja', 'rateSimpeda', 'rateDeposito', 'totalRateGiro', 'totalRateSimanja', 'totalRateSimpeda', 'shareNasabah', 'shareBank'];
function engine_view(array $r): array
{
$by = array_column($r['comparison'], null, 'key');
$ok = $r['eligible'];
$pr = $r['products'];
return [
'cashbackGross' => $ok ? $r['cashback']['gross'] : null, 'cashbackNet' => $ok ? $r['cashback']['net'] : null,
'giroNet' => $pr['giro']['bungaNet'], 'simanjaNet' => $pr['simanja']['bungaNet'], 'simpedaNet' => $pr['simpeda']['bungaNet'],
'depositoNet' => $r['deposito']['net'], 'depositoGross' => $r['deposito']['gross'], 'rateDeposito' => $r['deposito']['rate'],
'totalGiro' => $ok ? $by['giro']['total'] : null, 'totalSimanja' => $ok ? $by['simanja']['total'] : null, 'totalSimpeda' => $ok ? $by['simpeda']['total'] : null,
'totalDeposito' => $r['deposito']['net'],
'rateGiro' => $pr['giro']['rate'], 'rateSimanja' => $pr['simanja']['rate'], 'rateSimpeda' => $pr['simpeda']['rate'],
'equiv' => $ok ? $r['cashback']['equiv'] : null,
'totalRateGiro' => $ok ? $by['giro']['totalRate'] : null, 'totalRateSimanja' => $ok ? $by['simanja']['totalRate'] : null, 'totalRateSimpeda' => $ok ? $by['simpeda']['totalRate'] : null,
'penaltyRefund' => $ok ? $r['penalty']['refund'] : null, 'penaltyTax' => $ok ? $r['penalty']['tax'] : null,
'penaltyFee' => $ok ? $r['penalty']['fee'] : null, 'penaltyTotal' => $ok ? $r['penalty']['total'] : null,
'shareNasabah' => $ok ? $r['share'] : null, 'shareBank' => $ok ? $r['shareBank'] : null,
'income' => $r['fund']['income'], 'puabIncome' => $r['fund']['puabIncome'], 'creditIncome' => $r['fund']['creditIncome'],
'spreadSimanja' => $pr['simanja']['spread'], 'spreadSimpeda' => $pr['simpeda']['spread'], 'spreadGiro' => $pr['giro']['spread'],
];
}
$checks = 0;
$failures = [];
foreach ($fixtures['cases'] as $c) {
$got = engine_view(engine_calculate($params, (float)$c['nominal'], (int)$c['tenor'], 'perorangan'));
foreach ($c as $field => $expected) {
if ($field === 'nominal' || $field === 'tenor' || !array_key_exists($field, $got)) {
continue;
}
$checks++;
$actual = $got[$field];
if ($expected === null) {
$pass = $actual === null;
} elseif ($actual === null) {
$pass = false;
} elseif (in_array($field, $rateFields, true)) {
$pass = abs($actual - $expected) < 1e-9;
} else {
$pass = round($actual) == round($expected) && abs($actual - $expected) < 0.01;
}
if (!$pass) {
$failures[] = sprintf('%s / %d bln / %s: excel=%s app=%s', $c['nominal'], $c['tenor'], $field, var_export($expected, true), var_export($actual, true));
}
}
}
printf("PHP engine: %d/%d checks match Excel over %d cases\n", $checks - count($failures), $checks, count($fixtures['cases']));
foreach (array_slice($failures, 0, 20) as $f) {
echo " FAIL $f\n";
}
exit($failures ? 1 : 0);
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
/*
* Validates the public in-browser engine (assets/js/engine-public.js), which
* only receives the folded model of engine_client_model(), against the values
* recalculated by Microsoft Excel (tests/excel-fixtures.json). Only the fields
* a visitor sees are compared (no deposito, income or spread).
*
* node tests/validate_public.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const Engine = require('../assets/js/engine-public.js');
const root = path.join(__dirname, '..');
const fixtures = JSON.parse(fs.readFileSync(path.join(__dirname, 'excel-fixtures.json'), 'utf8').replace(/^\uFEFF/, ''));
const php = process.env.PHP_BIN || 'C:/laragon/bin/php/php-8.1.10-Win32-vs16-x64/php.exe';
const model = JSON.parse(execFileSync(php, ['-d', 'display_startup_errors=0', path.join(root, 'database/migrate.php'), '--dump-public-model'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }));
// What a visitor receives must not contain the internal parameters.
const leaked = ['gwm', 'kas', 'porsi_puab', 'porsi_kredit', 'puab', 'kredit', 'ohc', 'depositoRates', 'settings'].filter(k => JSON.stringify(model).includes('"' + k + '"'));
if (leaked.length) { console.log('Model memuat parameter internal:', leaked.join(', ')); process.exit(1); }
const close = (a, b, kind) => kind === 'rate' ? Math.abs(a - b) < 1e-9 : Math.round(a) === Math.round(b) && Math.abs(a - b) < 0.01;
const RATE_FIELDS = new Set(['equiv', 'rateGiro', 'rateSimanja', 'rateSimpeda', 'totalRateGiro', 'totalRateSimanja', 'totalRateSimpeda', 'shareNasabah']);
function view(r) {
const by = Object.fromEntries(r.comparison.map(c => [c.key, c]));
const ok = r.eligible;
return {
cashbackGross: ok ? r.cashback.gross : null, cashbackNet: ok ? r.cashback.net : null,
giroNet: by.giro.bungaNet, simanjaNet: by.simanja.bungaNet, simpedaNet: by.simpeda.bungaNet,
totalGiro: ok ? by.giro.total : null, totalSimanja: ok ? by.simanja.total : null, totalSimpeda: ok ? by.simpeda.total : null,
rateGiro: by.giro.rate, rateSimanja: by.simanja.rate, rateSimpeda: by.simpeda.rate,
equiv: ok ? r.cashback.equiv : null,
totalRateGiro: ok ? by.giro.totalRate : null, totalRateSimanja: ok ? by.simanja.totalRate : null, totalRateSimpeda: ok ? by.simpeda.totalRate : null,
penaltyRefund: ok ? r.penalty.refund : null, penaltyTax: ok ? r.penalty.tax : null, penaltyFee: ok ? r.penalty.fee : null, penaltyTotal: ok ? r.penalty.total : null,
shareNasabah: ok ? r.share : null
};
}
let checks = 0; const failures = [];
for (const c of fixtures.cases) {
const got = view(Engine.calculate(model, { nominal: c.nominal, tenor: c.tenor, customerType: 'perorangan' }));
for (const [field, expected] of Object.entries(c)) {
if (!(field in got)) continue;
checks++;
const actual = got[field];
const pass = expected === null ? actual === null : actual !== null && close(actual, expected, RATE_FIELDS.has(field) ? 'rate' : 'idr');
if (!pass) failures.push({ nominal: c.nominal, tenor: c.tenor, field, excel: expected, app: actual });
}
}
console.log(`Public engine (model koefisien): ${checks - failures.length}/${checks} checks match Excel over ${fixtures.cases.length} cases`);
failures.slice(0, 20).forEach(f => console.log(' FAIL', JSON.stringify(f)));
process.exit(failures.length ? 1 : 0);