From 3e4a056b14f7d80d3dc9e8c80f5d083ccdec7e06 Mon Sep 17 00:00:00 2001 From: "DIGIDEV\\corer" Date: Tue, 20 Oct 2026 13:16:38 +0900 Subject: [PATCH] asd --- .htaccess | 62 + .impeccable/design.json | 11 + .impeccable/surfaces/index-php.md | 20 + .serubi/.htaccess | 2 + .user.ini | 7 + DESIGN.md | 44 + PRODUCT.md | 56 + README.md | 47 + admin/2fa.php | 172 + admin/captcha.php | 12 + admin/error_log | 7 + admin/index.php | 170 + admin/login.php | 151 + admin/logout.php | 13 + admin/views/_tiers.php | 43 + admin/views/cashback.php | 52 + admin/views/deposito.php | 65 + admin/views/parameter.php | 57 + admin/views/pengguna.php | 101 + admin/views/produk.php | 27 + admin/views/riwayat.php | 69 + assets/css/admin.css | 345 ++ assets/css/app.css | 302 ++ assets/css/base.css | 220 + assets/favicon.svg | 1 + assets/fonts/archivo-var.woff2 | Bin 0 -> 90104 bytes assets/fonts/jetbrains-mono-var.woff2 | Bin 0 -> 31432 bytes assets/img/logo-bank-papua-white.png | Bin 0 -> 19817 bytes assets/img/logo-bank-papua.png | Bin 0 -> 12500 bytes assets/js/admin.js | 221 + assets/js/app.js | 430 ++ assets/js/engine-public.js | 99 + assets/js/engine.js | 157 + assets/js/format.js | 54 + assets/js/lib/qrcode.js | 2297 +++++++++++ assets/js/otp.js | 10 + assets/js/theme.js | 17 + calc.php | 43 + config.php | 51 + database/defaults.php | 53 + database/migrate.php | 106 + database/schema.sql | 85 + database/secrets.example.php | 20 + database/upgrade-2.3.0.sql | 26 + dbtest.php | 25 + docs/SECURITY.md | 88 + docs/VALIDASI.md | 52 + error_log | 117 + includes/admin_actions.php | 498 +++ includes/auth.php | 306 ++ includes/captcha.php | 95 + includes/db.php | 128 + includes/engine.php | 215 + includes/helpers.php | 244 ++ includes/params.php | 113 + includes/security.php | 153 + includes/totp.php | 123 + index.php | 214 + manifest.webmanifest | 14 + php.ini | 7 + sw.js | 75 + tests/excel-fixtures.json | 5431 +++++++++++++++++++++++++ tests/export-excel-fixtures.ps1 | 71 + tests/legacy-original.js | 29 + tests/validate.js | 77 + tests/validate_engine.php | 70 + tests/validate_public.js | 56 + 67 files changed, 13926 insertions(+) create mode 100644 .htaccess create mode 100644 .impeccable/design.json create mode 100644 .impeccable/surfaces/index-php.md create mode 100644 .serubi/.htaccess create mode 100644 .user.ini create mode 100644 DESIGN.md create mode 100644 PRODUCT.md create mode 100644 README.md create mode 100644 admin/2fa.php create mode 100644 admin/captcha.php create mode 100644 admin/error_log create mode 100644 admin/index.php create mode 100644 admin/login.php create mode 100644 admin/logout.php create mode 100644 admin/views/_tiers.php create mode 100644 admin/views/cashback.php create mode 100644 admin/views/deposito.php create mode 100644 admin/views/parameter.php create mode 100644 admin/views/pengguna.php create mode 100644 admin/views/produk.php create mode 100644 admin/views/riwayat.php create mode 100644 assets/css/admin.css create mode 100644 assets/css/app.css create mode 100644 assets/css/base.css create mode 100644 assets/favicon.svg create mode 100644 assets/fonts/archivo-var.woff2 create mode 100644 assets/fonts/jetbrains-mono-var.woff2 create mode 100644 assets/img/logo-bank-papua-white.png create mode 100644 assets/img/logo-bank-papua.png create mode 100644 assets/js/admin.js create mode 100644 assets/js/app.js create mode 100644 assets/js/engine-public.js create mode 100644 assets/js/engine.js create mode 100644 assets/js/format.js create mode 100644 assets/js/lib/qrcode.js create mode 100644 assets/js/otp.js create mode 100644 assets/js/theme.js create mode 100644 calc.php create mode 100644 config.php create mode 100644 database/defaults.php create mode 100644 database/migrate.php create mode 100644 database/schema.sql create mode 100644 database/secrets.example.php create mode 100644 database/upgrade-2.3.0.sql create mode 100644 dbtest.php create mode 100644 docs/SECURITY.md create mode 100644 docs/VALIDASI.md create mode 100644 error_log create mode 100644 includes/admin_actions.php create mode 100644 includes/auth.php create mode 100644 includes/captcha.php create mode 100644 includes/db.php create mode 100644 includes/engine.php create mode 100644 includes/helpers.php create mode 100644 includes/params.php create mode 100644 includes/security.php create mode 100644 includes/totp.php create mode 100644 index.php create mode 100644 manifest.webmanifest create mode 100644 php.ini create mode 100644 sw.js create mode 100644 tests/excel-fixtures.json create mode 100644 tests/export-excel-fixtures.ps1 create mode 100644 tests/legacy-original.js create mode 100644 tests/validate.js create mode 100644 tests/validate_engine.php create mode 100644 tests/validate_public.js diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..347df0f --- /dev/null +++ b/.htaccess @@ -0,0 +1,62 @@ +Options -Indexes -MultiViews +ServerSignature Off +DirectoryIndex index.php +AddType application/manifest+json .webmanifest + +# Plain error pages (no server banner / version). +ErrorDocument 400 "Bad request" +ErrorDocument 403 "Forbidden" +ErrorDocument 404 "Not found" +ErrorDocument 405 "Method not allowed" +ErrorDocument 500 "Server error" + +# Only GET, HEAD and POST are used by this app. + + Require all denied + + + + RewriteEngine On + # TRACE/TRACK are answered by Apache core unless rejected here (also set "TraceEnable Off" in httpd.conf). + RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK|OPTIONS|PUT|DELETE|PATCH|CONNECT)$ [NC] + RewriteRule .* - [F,L] + + +# Internal files are never served over HTTP. + + Require all denied + + + Require all denied + +# Dot-files and dot-directories (.git, .env, .impeccable, ...), except .well-known. +RedirectMatch 404 /\.(?!well-known/) +RedirectMatch 404 /(includes|database|tests|docs|design-system|node_modules|vendor)(/|$) +RedirectMatch 404 /admin/views(/|$) + + + Header always unset X-Powered-By + Header unset X-Powered-By + # JS/CSS/fonts/images are always requested with ?v=-, so a new + # upload changes the URL and clients fetch it immediately; old URLs can be + # cached for a long time. + + Header set Cache-Control "public, max-age=31536000, immutable" + Header set X-Content-Type-Options "nosniff" + Header set Cross-Origin-Resource-Policy "same-origin" + + # The service worker must be revalidated on every visit so updates reach the field. + + Header set Cache-Control "no-cache" + + + Header set Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; sandbox" + + + +# php -- BEGIN cPanel-generated handler, do not edit +# Set the “alt-php82” package as the default “PHP” programming language. + + AddHandler application/x-httpd-alt-php82___lsphp .php .php8 .phtml + +# php -- END cPanel-generated handler, do not edit diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..65e75b9 --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,11 @@ +{ + "world": "bank-desk printing calculator with paper tape", + "fonts": { "ui": "Archivo (variable, self-hosted)", "tape": "JetBrains Mono (self-hosted)" }, + "color": { + "light": { "desk": "#e7ebf2", "machine": "#0b2e66", "key": "#143c7c", "gold": "#f2c14e", "display": "#04132f", "displayInk": "#ffe6a0", "paper": "#fffef8", "paperInk": "#1a2130", "good": "#0a7140", "bad": "#b42318" }, + "dark": { "desk": "#070c17", "machine": "#0c2a5c", "key": "#11346d", "gold": "#f2c14e", "display": "#020a1c", "displayInk": "#ffe6a0", "paper": "#ebe7db", "paperInk": "#191f2b" } + }, + "radius": { "s": 8, "m": 12, "l": 22 }, + "motion": { "durationMs": [150, 250], "ease": "cubic-bezier(.16,1,.3,1)", "signature": "tape feed on recalculation" }, + "breakpoints": { "calcTwoColumn": 900, "adminSide": 960, "adminPreviewColumn": 1320 } +} diff --git a/.impeccable/surfaces/index-php.md b/.impeccable/surfaces/index-php.md new file mode 100644 index 0000000..34f24c8 --- /dev/null +++ b/.impeccable/surfaces/index-php.md @@ -0,0 +1,20 @@ +--- +version: 1 +slug: "index-php" +primary_target: "index.php" +related_targets: ["admin/index.php"] +--- + +# Surface: SERUBI calculator (index.php) + admin (admin/) + +Mode: Operate. Audience: Bank Papua branch CS/marketing staff presenting to a customer across the desk; admins maintaining ALCO parameters. +Task: enter nominal + tenor + customer type, read cashback neto and comparison vs deposito, explain the steps, show penalty. + +## Direction contract +THESIS: The calculator is a bank desk printing calculator: one luminous display carries the answer, a paper tape prints every step. Refuses the category default of KPI cards + generic table. +OWN-WORLD: Navy machine body (#0a2a5c family) with gold keys/accents (Bank Papua), a recessed display panel with tabular numerals, segmented tenor keys with physical press state, off-white receipt paper with perforated/zigzag edge, dashed rules, monospace-feel tabular numbers on the tape. Hairline ruled comparison table. Light and dark themes; dark = machine at night. +STORY: Staff types nominal, taps tenor key; display updates instantly with cashback neto and annual equivalent; tape prints the derivation and the product comparison; customer sees the bigger number vs deposito. +FIRST VIEWPORT: Desktop: left column = machine (inputs as keys + display, headline neto at ~44px), right column = receipt tape with the full breakdown. Mobile: machine on top, display sticky-compact, tape below. +FORM: Printing calculator + tape; grounded list position 7; seed key 52059e85. Raises: hairline precision ruling (centre-rail), one message per line (terminal wayfinding). +Signature interaction: on recalculation the tape "feeds" new lines (short translate, reduced-motion = none). +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance diff --git a/.serubi/.htaccess b/.serubi/.htaccess new file mode 100644 index 0000000..966ec85 --- /dev/null +++ b/.serubi/.htaccess @@ -0,0 +1,2 @@ +# Secrets folder: never served over HTTP. +Require all denied diff --git a/.user.ini b/.user.ini new file mode 100644 index 0000000..6c73819 --- /dev/null +++ b/.user.ini @@ -0,0 +1,7 @@ +; cPanel-generated php ini directives, do not edit +; Manual editing of this file may result in unexpected behavior. +; To make changes to this file, use the cPanel MultiPHP INI Editor (Home >> Software >> MultiPHP INI Editor) +; For more information, read our documentation (https://go.cpanel.net/EA4ModifyINI) + +error_log = "/home/k5551916/logs/php.error.log" +log_errors = On diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..8f2912c --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,44 @@ +# Design — SERUBI Cashback Calculator + +World: a bank-desk **printing calculator**. A navy machine body holds the inputs and one recessed display that carries the answer. A paper tape prints every figure and every step. The admin uses the same machine body as its side panel, with plain white work surfaces for editing. + +## Tokens (assets/css/base.css) + +| Role | Light | Dark | +|---|---|---| +| Desk (page ground) `--desk` | #e7ebf2 | #070c17 | +| Machine body `--machine` / hi / lo | #0b2e66 / #154290 / #072153 | #0c2a5c / #12387a / #071a40 | +| Key `--key` / edge | #143c7c / #061a40 | #11346d / #061a40 | +| Gold (Bank Papua accent) `--gold` / ink | #f2c14e / #241800 | same | +| Display `--display` / ink / dim | #04132f / #ffe6a0 / #c9b98c | #020a1c / same | +| Paper `--paper` / ink / muted / rule | #fffef8 / #1a2130 / #545d70 / #c3c7cf | #ebe7db / #191f2b / #4c5464 / #b4b1a6 | +| Good / bad | #0a7140 / #b42318 | same on paper | + +Radius: 8 / 12 / 22px (machine). Motion: 150–250ms, `cubic-bezier(.16,1,.3,1)`; one signature motion (the tape "feeds" 10px on recalculation); all motion off under `prefers-reduced-motion`. + +## Type + +- **Archivo** (variable, self-hosted, `font-stretch` 62–125%): UI, labels, and display numerals (800 weight at ~78% width, tabular). +- **JetBrains Mono** (self-hosted): the paper tape only, because it is printed data. +- Labels on the machine: 0.8rem uppercase, 0.04–0.06em tracking. + +## Components + +- **Key**: navy fill with an inset 3px bottom edge for depth. Pressed: moves down 2px and the edge shrinks. The gold key is the primary action (print). +- **Segmented keys** (tenor, customer type): radio inputs. The checked key turns gold. +- **Display**: a recessed panel with the headline value right-aligned, a dashed rule, and three sub-values. When there is no cashback it dims and shows a note explaining why. +- **Tape**: dotted leader lines (`label ····· value`), strong rows ruled above and below, collapsible sections, and a torn zig-zag bottom edge done with a CSS mask. +- **Comparison row**: product name, total, a two-part bar (navy = bunga neto, gold = cashback neto), four metadata cells, and the difference vs deposito. The highest total gets a "Tertinggi" tag; deposito has a dashed border. +- **Admin**: sticky navy side panel (on phones it becomes a sticky top strip with scrolling tabs), white panels, editable tier tables (shown as cards on phones), a save bar for unsaved changes, and a live "Pratinjau dampak" panel. + +## Layout + +- Calculator: one column below 900px. From 900px, the machine (360–460px, sticky) sits beside the tape. On phones, a compact display bar appears at the bottom once the main display scrolls out of view. +- Admin: side panel + main from 960px. The preview gets a third column from 1320px. +- Verified at 320, 390, 820, 1100, 1280, and 1440px with no horizontal page scroll. + +## Rules + +- Brand = the official Bank Papua logo (`bank_logo()` in includes/helpers.php, files in assets/img/, sourced, never redrawn) + the gold "SERUBI" tag. Color logo on light surfaces and paper/print; white logo on navy and in dark mode (`'auto'` switches by theme). +- Numbers use tabular figures; Rupiah is formatted `Rp1.234.567`, percentages `2,70%`. +- Print shows only the tape. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..a462d9f --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,56 @@ +# Product + + + +## Platform + +web + +## Stack + +PHP 8.1 native (no framework) + MySQL 8 on Laragon (`http://localhost/calcserubi`, MySQL `localhost:3309`). Chosen by the user over Laravel / Node. + +## Users + +Branch staff of Bank Papua (customer service and marketing) who offer the SERUBI cashback program to prospective customers. They run simulations at the desk or in the field, often turning the screen toward the customer (PC, tablet, or phone). A small number of head-office/branch admins maintain the calculation parameters when ALCO rates or bank parameters change. + +## Product Purpose + +Simulate how much a customer receives when placing funds in Giro / Tabungan SIMANJA / Tabungan SIMPEDA plus the SERUBI cashback, compared with placing the same funds in a Deposito. Success: staff can produce a correct, explainable number in seconds that matches the official branch Excel calculator, and admins can update rates without editing code. + +## Positioning + +The single source of the official SERUBI formula: the same spread-sharing calculation as the branch Excel file (`CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx`), with ALCO rate tables and bank parameters managed centrally in a database instead of copied spreadsheets. + +## Operating Context + +- Input: nominal placement (Rupiah), tenor (1/3/6/12/24 months), customer type (perorangan / badan usaha). +- Output: cashback bruto/neto, annual equivalent rate, per-product comparison vs. deposito, cashback penalty on early withdrawal. +- Parameters come from ALCO decisions (deposito, SIMANJA, SIMPEDA, Giro rate tiers) and bank-level parameters (GWM, Kas, PUAB, credit rate, overhead cost, LPS premium, taxes, cashback sharing tiers). +- Language: Bahasa Indonesia, Rupiah formatting (`id-ID`). + +## Capabilities and Constraints + +- Cashback applies only to tenors 3, 6, 12, 24 months and nominal ≥ Rp50.000.000. +- Admin access requires login (hashed passwords, multiple admin users) and every parameter change is logged. +- Results are internal estimates; the Excel workbook is the validation reference. + +## Brand Commitments + +Bank Papua identity: deep navy/blue and gold. Program name "SERUBI". + +## Evidence on Hand + +- Reference workbook: `C:\Users\corer\Downloads\CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx` (sheets `COB`, `CALCULATOR CASHBACK SERUBI`). +- No logo file in the project; do not fabricate one. + +## Product Principles + +1. The number must match the official Excel; correctness over decoration. +2. Explainable: every figure can be traced to its parameter. +3. Fast to present across the desk: the headline answer is readable at arm's length. +4. Parameters change by data, never by code. + +## Accessibility & Inclusion + +Used on older branch PCs and budget Android phones; must work at 320px width, keyboard-only, and WCAG AA contrast. diff --git a/README.md b/README.md new file mode 100644 index 0000000..baf9b0e --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Kalkulator Cashback SERUBI — Bank Papua + +Simulasi Tabungan/Giro + cashback SERUBI dibandingkan deposito, dengan halaman admin untuk mengubah parameter. PHP native + MySQL. + +- Kalkulator: `http://localhost/calcserubi/` +- Admin: `http://localhost/calcserubi/admin/` (saat pertama dibuka, Anda diminta membuat akun admin) + +## Instalasi + +1. Atur koneksi di `config.php` (default `127.0.0.1:3309`, user `root`, database `calcserubi`). Di server, nilai ini bisa diganti lewat environment variable `SERUBI_DB_HOST/PORT/NAME/USER/PASS`. +2. Buka aplikasinya. Database, tabel, dan parameter awal dari file Excel dibuat otomatis. Bisa juga lewat CLI: `php database/migrate.php`. +3. Buka `/admin/` dan buat akun admin pertama. + +Perintah lain: + +- `php database/migrate.php --reseed` mengembalikan semua parameter ke nilai Excel. +- `php database/migrate.php --dump-params` menampilkan parameter aktif dalam format JSON. + +## Versi JS/CSS (update langsung terlihat di klien) + +Semua file JS/CSS dimuat dengan `?v=-` (lihat `asset()` di `includes/helpers.php`): + +- Setiap file yang diunggah ulang ke server otomatis mendapat URL baru, sehingga browser langsung memuat versi terbaru. +- Halaman PHP dikirim dengan `Cache-Control: no-cache`, jadi selalu merujuk versi asset terbaru. +- Asset boleh di-cache lama (`.htaccess`) karena URL-nya selalu berubah saat ada update. +- Untuk menandai rilis, naikkan `'version'` di `config.php`. Versi juga tampil di ``. + +## Struktur + +| Path | Isi | +|---|---| +| `assets/js/engine.js` | Rumus perhitungan (port dari sheet COB), dipakai kalkulator, preview admin, dan test | +| `index.php`, `assets/js/app.js` | Halaman kalkulator | +| `admin/` | Login, parameter bank, rate produk, rate deposito, porsi cashback, pengguna, riwayat | +| `includes/` | Koneksi DB, pemuatan parameter, auth, aksi admin | +| `database/` | `schema.sql`, `defaults.php` (nilai Excel), `migrate.php` | +| `tests/` | Validasi terhadap Excel, lihat `docs/VALIDASI.md` | + +## Validasi + +`node tests/validate.js` membandingkan engine (dengan parameter dari MySQL) terhadap 155 skenario yang dihitung oleh Excel. Hasil saat ini: 4.805/4.805 cocok. Detail ada di [docs/VALIDASI.md](docs/VALIDASI.md). + +## Keamanan + +Detail lengkap dan checklist server ada di [docs/SECURITY.md](docs/SECURITY.md). Wajib dikerjakan setelah deploy: HTTPS + `SERUBI_FORCE_HTTPS=1`, `ServerTokens Prod` dan `TraceEnable Off` di httpd.conf, user MySQL khusus (`php database/migrate.php --create-db-user`), dan PHP yang masih didukung. + +Membuat admin pertama di server baru: `php database/migrate.php --create-admin`. diff --git a/admin/2fa.php b/admin/2fa.php new file mode 100644 index 0000000..498a466 --- /dev/null +++ b/admin/2fa.php @@ -0,0 +1,172 @@ += OTP_MAX_FAILS || login_locked()) { + unset($_SESSION['pre_auth']); + redirect(base_url('admin/login.php?reason=otp')); + } + if (preg_match('/^[A-Z2-7]{4}-?[A-Z2-7]{4}$/', $code)) { + // One-time recovery code (lost phone) + $hashes = json_decode((string)$row['recovery_codes'], true) ?: []; + $i = array_search(recovery_hash($code), $hashes, true); + if ($i !== false) { + array_splice($hashes, (int)$i, 1); + db()->prepare('UPDATE admins SET recovery_codes = ? WHERE id = ?')->execute([json_encode($hashes), $row['id']]); + record_otp_attempt($row['username'], true); + complete_login($row, 'kode pemulihan'); + flash('info', 'Anda masuk dengan kode pemulihan. Sisa kode: ' . count($hashes) . '. Minta super admin me-reset OTP bila ponsel hilang.'); + redirect(base_url('admin/')); + } + } elseif ($step = totp_verify(secret_decrypt((string)$row['otp_secret']), $code, $row['otp_last_step'] !== null ? (int)$row['otp_last_step'] : null)) { + db()->prepare('UPDATE admins SET otp_last_step = ? WHERE id = ?')->execute([$step, $row['id']]); + record_otp_attempt($row['username'], true); + complete_login($row, 'OTP'); + redirect(base_url('admin/')); + } + record_otp_attempt($row['username'], false); + $left = OTP_MAX_FAILS - otp_failures($row['username']); + $error = $left > 0 ? "Kode salah atau sudah dipakai. Sisa percobaan: $left." : 'Terlalu banyak kode salah.'; + if ($left <= 0) { + unset($_SESSION['pre_auth']); + redirect(base_url('admin/login.php?reason=otp')); + } + } + + if ($mode === 'enroll') { + app_key(); // fail early when the encryption key is not configured + $_SESSION['pre_auth']['exp'] = time() + 2 * PRE_AUTH_TTL; // time to install the app + if (empty($_SESSION['pre_auth']['secret'])) { + $_SESSION['pre_auth']['secret'] = totp_new_secret(); + } + $secret = $_SESSION['pre_auth']['secret']; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + csrf_check(); + if (otp_failures($row['username']) >= OTP_MAX_FAILS || login_locked()) { + unset($_SESSION['pre_auth']); + redirect(base_url('admin/login.php?reason=otp')); + } + $step = totp_verify($secret, input_str($_POST['code'] ?? ''), null); + if ($step) { + [$plain, $hashes] = recovery_codes_new(); + db()->prepare('UPDATE admins SET otp_secret = ?, otp_enabled = 1, otp_last_step = ?, recovery_codes = ? WHERE id = ?') + ->execute([secret_encrypt($secret), $step, json_encode($hashes), $row['id']]); + record_otp_attempt($row['username'], true); + complete_login($row, 'OTP, aktivasi'); + audit('user.otp', 'Mengaktifkan autentikasi dua faktor (OTP)'); + $_SESSION['recovery_once'] = $plain; + redirect(base_url('admin/2fa.php?done=1')); + } + record_otp_attempt($row['username'], false); + $error = 'Kode tidak cocok. Pastikan jam ponsel sudah otomatis, lalu ketik kode yang sedang tampil.'; + } + } +} catch (RuntimeException $ex) { + error_log('[serubi] 2fa: ' . $ex->getMessage()); + $configError = 'Autentikasi dua faktor belum dapat dipakai karena konfigurasi server belum lengkap (security.app_key). Hubungi pengelola server.'; +} +?> + + + + + + + +Verifikasi dua langkah · SERUBI + + + + + + +
+ SERUBI + + + + + + +
+ + +
+ +
+ + + + + diff --git a/admin/captcha.php b/admin/captcha.php new file mode 100644 index 0000000..6387cfc --- /dev/null +++ b/admin/captcha.php @@ -0,0 +1,12 @@ + ['Parameter bank', 'sliders', 'GWM, kas, PUAB, suku bunga kredit, biaya, pajak, dan konvensi hari bunga.'], + 'produk' => ['Rate tabungan & giro', 'wallet', 'Rate bunga SIMANJA, SIMPEDA, dan Giro Umum per kelompok nominal sesuai ALCO.'], + 'deposito' => ['Rate deposito', 'grid', 'Rate deposito pembanding per kelompok nominal dan jangka waktu sesuai ALCO.'], + 'cashback' => ['Porsi cashback', 'pie', 'Pembagian spread antara nasabah dan bank, serta jangka waktu yang mendapat cashback.'], + 'pengguna' => ['Pengguna admin', 'users', 'Kelola akun yang dapat mengubah parameter.'], + 'riwayat' => ['Riwayat perubahan', 'history', 'Siapa mengubah apa dan kapan.'], +]; +$tab = input_str($_GET['tab'] ?? 'parameter'); +if (!isset($tabs[$tab])) { + $tab = 'parameter'; +} +// A reset or initial password must be replaced before anything else. +if ($me['must_change']) { + $tab = 'pengguna'; +} + +$failed = null; // ['errors' => [...], 'draft' => [...]] when a POST did not validate +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + csrf_check(); + $action = input_str($_POST['action'] ?? ''); + $handlers = [ + 'save_settings' => fn() => action_save_settings(), + 'save_products' => fn() => action_save_products(), + 'save_deposito' => fn() => action_save_deposito(), + 'save_cashback' => fn() => action_save_cashback(), + 'reset_defaults' => fn() => action_reset_defaults(), + 'user_create' => fn() => action_user_create($me), + 'user_toggle' => fn() => action_user_toggle($me), + 'user_password' => fn() => action_user_password($me), + 'user_role' => fn() => action_user_role($me), + 'user_reset_otp' => fn() => action_user_reset_otp($me), + 'own_password' => fn() => action_own_password($me), + ]; + if ($me['must_change'] && $action !== 'own_password') { + $action = ''; + } + if (isset($handlers[$action])) { + $failed = $handlers[$action](); + } +} + +$params = load_params(); +$flash = flash(); +$errors = $failed['errors'] ?? []; +$draft = $failed['draft'] ?? null; +$hasPreview = in_array($tab, ['parameter', 'produk', 'deposito', 'cashback'], true); + +function field_error(array $errors, string $key): string +{ + return isset($errors[$key]) ? '' : ''; +} +?> + + + + + + + +<?= e($tabs[$tab][0]) ?> · Admin SERUBI + + + + + + + + +
+ + +
+
+

+

+ +

Terakhir diubah

+ +
+ + +

Password akun Anda baru dibuat atau di-reset. Ganti password di bawah sebelum memakai menu lain.

+ + +

+ + + + + + +
+ + + + +
+ + + + + + + + + + + diff --git a/admin/login.php b/admin/login.php new file mode 100644 index 0000000..4a1333f --- /dev/null +++ b/admin/login.php @@ -0,0 +1,151 @@ += 16; +$error = null; +// Why the previous session ended (whitelisted values only). +$notices = [ + 'elsewhere' => 'Akun Anda masuk dari perangkat atau browser lain, sehingga sesi ini diakhiri. Masuk lagi untuk melanjutkan.', + 'password' => 'Password akun ini telah diganti. Silakan masuk dengan password baru.', + 'inactive' => 'Sesi diakhiri karena akun tidak aktif.', + 'expired' => 'Waktu verifikasi habis. Silakan masuk lagi.', + 'otp' => 'Terlalu banyak kode OTP salah. Silakan masuk lagi dalam ' . LOGIN_WINDOW_MIN . ' menit.', +]; +$notice = $notices[input_str($_GET['reason'] ?? '')] ?? null; +$username = ''; +$name = ''; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + csrf_check(); + $username = strtolower(trim(input_str($_POST['username'] ?? ''))); + $password = input_str($_POST['password'] ?? ''); + // The CAPTCHA is checked first, before any password is verified. + $captchaOk = captcha_check(input_str($_POST['captcha'] ?? '')); + if (login_locked()) { + $error = 'Terlalu banyak percobaan gagal dari jaringan ini. Coba lagi dalam ' . LOGIN_WINDOW_MIN . ' menit.'; + } elseif (!$captchaOk) { + $error = 'Kode keamanan (CAPTCHA) salah atau kedaluwarsa. Ketik kode pada gambar yang baru.'; + } elseif ($setup) { + $name = trim(input_str($_POST['full_name'] ?? '')); + $sentKey = input_str($_POST['setup_key'] ?? ''); + if (!$setupAllowed) { + $error = 'Pembuatan admin pertama tidak diizinkan dari sini.'; + } elseif (!hash_equals($setupKey, $sentKey)) { + db()->prepare('INSERT INTO login_attempts (ip, username, success) VALUES (?,?,0)')->execute([client_ip(), 'setup']); + $error = 'Kode setup salah.'; + } else { + $error = validate_new_admin($username, $name, $password, input_str($_POST['password_confirm'] ?? '')); + } + if (!$error && admin_count() === 0) { + // The first admin is the super admin; the authenticator is set up next. + db()->prepare("INSERT INTO admins (username, full_name, role, password_hash) VALUES (?,?,'super_admin',?)") + ->execute([$username, $name, password_hash($password, PASSWORD_DEFAULT)]); + db()->prepare("INSERT INTO audit_log (action, summary, ip) VALUES ('user.create', ?, ?)") + ->execute(["Membuat akun admin pertama: $username", client_ip()]); + begin_second_factor(verify_credentials($username, $password)); + redirect(base_url('admin/2fa.php')); + } + } elseif ($row = verify_credentials($username, $password)) { + begin_second_factor($row); + redirect(base_url('admin/2fa.php')); + } else { + $error = 'Username atau password salah.'; + } +} +captcha_new(); // every rendered form gets a fresh challenge +$captchaQuestion = captcha_question(); +?> + + + + + + + +<?= $setup ? 'Buat Akun Admin' : 'Masuk Admin' ?> · SERUBI + + + + + + +
+ SERUBI + + Kembali ke kalkulator +
+ + diff --git a/admin/logout.php b/admin/logout.php new file mode 100644 index 0000000..1dec7ee --- /dev/null +++ b/admin/logout.php @@ -0,0 +1,13 @@ + str, 'rate' => str]), $tierId. +$last = count($rows) - 1; +?> +
+ + + + + + + + + + $r): ?> + + + + + + + +
NominalRate p.a.Hapus
+
+ +
+ Rp + +
+
+
+
+ + % +
+
+ +
+ +
diff --git a/admin/views/cashback.php b/admin/views/cashback.php new file mode 100644 index 0000000..a18eb89 --- /dev/null +++ b/admin/views/cashback.php @@ -0,0 +1,52 @@ + ['min' => fmt_idr($t['min']), 'share' => fmt_pct($t['nasabah'])], $params['cashbackTiers']); + $cbTenors = $params['cashbackTenors']; +} +?> +
+ + + +
+

Jangka waktu program

Hanya jangka waktu yang dicentang yang mendapat cashback.

+
+ + + +
+
+ +
+

Porsi spread untuk nasabah

Tingkat dengan nominal minimal tertinggi yang tercapai yang dipakai. Porsi bank = 100% − porsi nasabah. Nominal di bawah tingkat terendah tidak mendapat cashback.

+ + + + + $r): ?> + + + + + + + + +
Nominal minimalPorsi nasabahPorsi bankHapus
+
Rp
+
+
%
+
—
+ +
+
+ +
+
diff --git a/admin/views/deposito.php b/admin/views/deposito.php new file mode 100644 index 0000000..924b1bf --- /dev/null +++ b/admin/views/deposito.php @@ -0,0 +1,65 @@ + $_) { + foreach ($tenors as $t) { + $rates[$i][$t] = fmt_pct($params['depositoRates'][$t][$i]['rate'] ?? 0); + } + } +} +$last = count($bands) - 1; +?> +
+ + +

Nominal di bawah Rp tidak mendapat bunga deposito (atur di Parameter bank). Rate dalam persen per tahun.

+
+

Tabel rate deposito

Baris = kelompok nominal, kolom = jangka waktu.

+ +
+ + + + + + + + + + $max): ?> + + + + + + + + + +
Nominal blnHapus
+
+ +
+ Rp + +
+
+
+
+ + % +
+
+
+ +
+
+ +
+
diff --git a/admin/views/parameter.php b/admin/views/parameter.php new file mode 100644 index 0000000..887a1ed --- /dev/null +++ b/admin/views/parameter.php @@ -0,0 +1,57 @@ + $row) { + $byGroup[$row['grp']][$key] = $row; +} +$unitSuffix = ['pct' => '%', 'idr' => null, 'days' => 'hari']; +?> +
+ + + [$title, $desc]): if (empty($byGroup[$grp])) continue; ?> +
+

+
+ $row): + $val = $draft[$key] ?? fmt_setting($row); + $err = isset($errors["s.$key"]); + $id = 's-' . $key; ?> +
+
+ +

+ +
+
+ Rp + > + +
+
+ +
+
+ +
+ +
+
+ +
> +

Kembalikan ke nilai file Excel

+
+ + +

Semua parameter, rate produk, rate deposito, dan porsi cashback diganti dengan nilai dari CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx. Perubahan tercatat di riwayat.

+
+ + + +
+ +
+
diff --git a/admin/views/pengguna.php b/admin/views/pengguna.php new file mode 100644 index 0000000..9111116 --- /dev/null +++ b/admin/views/pengguna.php @@ -0,0 +1,101 @@ +query('SELECT id, username, full_name, role, is_active, otp_enabled, must_change_password, last_login_at, created_at FROM admins ORDER BY is_active DESC, username')->fetchAll(); +$openId = (int)($errors['user_id'] ?? 0); +$super = is_super_admin($me); +// Every action on another account asks for the acting admin's own password. +$confirmField = function (string $id): string { + return '
' + . '
'; +}; +?> +
+
+

Daftar admin

+

+
+ +
    + +
  • +
    + Anda' : '' ?> + + OTP belum aktif' ?> + Wajib ganti password' : '' ?> + Nonaktif' ?> + @ · login terakhir + +
    + + + +
  • + +
+
+ +
+ +
+

Tambah admin

+
+ + + +

Huruf kecil, angka, titik, minus, garis bawah.

+
+
+
+

Minimal 10 karakter, berisi huruf, angka, dan simbol. Wajib diganti saat login pertama.

+
+ + +
+
+ + +
+

Password Anda

+
+ + + +
+
+
+ +
+
+
diff --git a/admin/views/produk.php b/admin/views/produk.php new file mode 100644 index 0000000..2753396 --- /dev/null +++ b/admin/views/produk.php @@ -0,0 +1,27 @@ + $label) { + if ($draft !== null) { + $tierRows[$key] = array_values((array)($draft[$key] ?? [])); + } else { + $tierRows[$key] = array_map(fn($t) => ['max' => fmt_idr($t['max']), 'rate' => fmt_pct($t['rate'])], $params['productRates'][$key]); + } +} +?> +
+ + +

Tingkat dibaca dari atas: nominal sampai dengan batas pertama memakai rate pertama, dan seterusnya. Baris terakhir berlaku untuk semua nominal di atasnya.

+ $label): ?> +
+

+ + +
+ +
+ +
+
diff --git a/admin/views/riwayat.php b/admin/views/riwayat.php new file mode 100644 index 0000000..91fc069 --- /dev/null +++ b/admin/views/riwayat.php @@ -0,0 +1,69 @@ +query('SELECT COUNT(*) FROM audit_log')->fetchColumn(); +$pages = max(1, (int)ceil($total / $perPage)); +$page = min($page, $pages); +$st = db()->prepare('SELECT * FROM audit_log ORDER BY id DESC LIMIT ? OFFSET ?'); +$st->bindValue(1, $perPage, PDO::PARAM_INT); +$st->bindValue(2, ($page - 1) * $perPage, PDO::PARAM_INT); +$st->execute(); +$logs = $st->fetchAll(); + +$fmtVal = function ($v, string $unit): string { + return $unit === 'pct' ? fmt_pct((float)$v) . '%' : ($unit === 'idr' ? 'Rp' . fmt_idr((float)$v) : (string)$v); +}; +/** One line per tier: "≤ Rp100.000.000: 0,75%". */ +$fmtTiers = function (array $tiers): string { + return implode(' · ', array_map(fn($t) => ($t['max'] === null ? 'di atas' : '≤ Rp' . fmt_idr((float)$t['max'])) . ': ' . fmt_pct((float)$t['rate']) . '%', $tiers)); +}; +?> +
+ +

Belum ada aktivitas. Setiap perubahan parameter, login, dan pengelolaan admin akan tercatat di sini.

+ +
    + +
  1. +
    + + · +
    + +
      + +
    • : →
    • + +
    + +
      + $d): ?> +


    • + +
    + +
      + $tiers): if (json_encode($tiers) === json_encode($ch['old'][$tenor] ?? null)) continue; ?> +
    • bulan

    • + +
    + +
      + 'del', 'new' => 'ins'] as $k => $tag): ?> +
    • <>Tenor bln · '≥ Rp' . fmt_idr((float)$t['min']) . ': ' . fmt_pct((float)$t['nasabah']) . '%', $ch[$k]['tiers']))) ?>>
    • + +
    + +
  2. + +
+ 1): ?> + + + +
diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..5edaf96 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,345 @@ +/* Admin: navy machine side panel, paper work surface. */ +:root { + --surface: #ffffff; + --surface-2: #f5f7fb; + --line: #d6dde8; + --ink: #14223b; + --muted: #536079; + --primary: #0b2e66; + --primary-hi: #154290; + --danger: #b42318; + --ok-bg: #e5f5ec; + --ok-ink: #0a5a33; + --err-bg: #fde9e7; + --err-ink: #8f1d13; + --info-bg: #e8effb; + --info-ink: #173d78; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --surface: #0f1829; --surface-2: #0b1322; --line: #24314a; --ink: #e4e9f3; --muted: #9ba7bc; + --primary: #f2c14e; --primary-hi: #f8d377; + --ok-bg: #0d2c1d; --ok-ink: #8fe0b2; --err-bg: #3a1411; --err-ink: #ffb4a8; --info-bg: #13233f; --info-ink: #b9cdf0; + } +} +:root[data-theme="dark"] { + --surface: #0f1829; --surface-2: #0b1322; --line: #24314a; --ink: #e4e9f3; --muted: #9ba7bc; + --primary: #f2c14e; --primary-hi: #f8d377; + --ok-bg: #0d2c1d; --ok-ink: #8fe0b2; --err-bg: #3a1411; --err-ink: #ffb4a8; --info-bg: #13233f; --info-ink: #b9cdf0; +} +:root[data-theme="dark"] .btn-primary, :root[data-theme="dark"] .btn-primary:hover { color: var(--gold-ink); } +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .btn-primary { color: var(--gold-ink); } } + +body.page-admin, body.page-login { color: var(--ink); } +.muted { color: var(--muted); } + +/* ---- buttons & fields ---- */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + min-height: 44px; padding: 0 16px; border-radius: 10px; border: 1px solid transparent; + font-weight: 650; font-size: .95rem; text-decoration: none; white-space: nowrap; + transition: background-color .15s, border-color .15s, color .15s; +} +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-hi); } +.btn-ghost { background: transparent; border-color: var(--line); color: var(--ink); } +.btn-ghost:hover { background: var(--surface-2); border-color: var(--muted); } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover { background: #8f1d13; } +.btn-sm { min-height: 38px; padding: 0 12px; font-size: .88rem; } +@media (pointer: coarse) { .btn-sm { min-height: 44px; } } +.btn-block { width: 100%; } +.btn:disabled { opacity: .55; cursor: not-allowed; } +.icon-btn:disabled { opacity: .35; cursor: not-allowed; } + +.form-field { display: grid; gap: 6px; min-width: 0; } +.form-field > label { font-weight: 650; font-size: .9rem; } +.hint { font-size: .82rem; color: var(--muted); } +input:not([type=checkbox]):not([type=radio]), select { + width: 100%; min-height: 44px; padding: 0 12px; border-radius: 9px; + border: 1px solid var(--line); background: var(--surface); color: var(--ink); font-size: 1rem; + transition: border-color .15s, box-shadow .15s; +} +input:hover, select:hover { border-color: var(--muted); } +input:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent); } +input[aria-invalid="true"], .is-invalid input { border-color: var(--danger); } + +.input-affix { display: flex; align-items: center; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); min-width: 0; transition: border-color .15s, box-shadow .15s; } +.input-affix:hover { border-color: var(--muted); } +.input-affix:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent); } +.input-affix > span { padding: 0 10px; color: var(--muted); font-weight: 600; font-size: .9rem; white-space: nowrap; } +.input-affix > input { border: 0 !important; box-shadow: none !important; background: transparent; text-align: right; padding: 0 4px; min-width: 0; } +.input-affix > input:first-child { padding-left: 12px; } +.input-affix > input:last-child { padding-right: 12px; } +.is-invalid .input-affix { border-color: var(--danger); } + +.alert { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; margin-bottom: 16px; font-weight: 550; } +.alert .icon { margin-top: 3px; } +.alert-ok { background: var(--ok-bg); color: var(--ok-ink); } +.alert-error { background: var(--err-bg); color: var(--err-ink); } +.alert-info { background: var(--info-bg); color: var(--info-ink); } +.field-msg { display: flex; gap: 6px; align-items: flex-start; color: var(--danger); font-size: .86rem; font-weight: 600; margin: 6px 0; } +:root[data-theme="dark"] .field-msg { color: var(--err-ink); } +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .field-msg { color: var(--err-ink); } } +.field-msg .icon { margin-top: 2px; } +.hint-block { display: flex; gap: 10px; align-items: flex-start; font-size: .9rem; color: var(--info-ink); background: var(--info-bg); padding: 11px 14px; border-radius: 10px; margin-bottom: 16px; } +.hint-block .icon { margin-top: 3px; } + +/* ---- login ---- */ +.login { min-height: 100vh; min-height: 100dvh; display: grid; place-content: center; gap: 18px; padding: 24px 16px; justify-items: center; } +.login-card { + width: min(400px, calc(100vw - 32px)); display: grid; gap: 16px; + background: var(--surface); border: 1px solid var(--line); border-radius: 18px; padding: 26px 22px; + box-shadow: 0 24px 40px -28px rgba(10, 30, 70, .45); +} +.login-card h1 { font-size: 1.35rem; font-weight: 800; font-stretch: 92%; } +.back-link { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); min-height: 44px; font-weight: 600; } +.captcha-img { display: block; width: 200px; max-width: 100%; height: auto; border: 1px solid var(--line); border-radius: 9px; margin-bottom: 6px; } +.otp-steps { display: grid; gap: 12px; padding-left: 1.2em; font-size: .92rem; } +.otp-qr { width: 196px; max-width: 100%; margin-top: 8px; background: #fff; border-radius: 10px; overflow: hidden; } +.otp-qr svg { display: block; width: 100%; height: auto; } +.otp-secret { display: block; margin-top: 6px; padding: 8px 10px; border-radius: 8px; background: var(--surface-2); border: 1px solid var(--line); font-size: .95rem; letter-spacing: .04em; word-break: break-all; } +.recovery-codes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; padding-left: 1.4em; font-size: 1rem; } +.recovery-codes code { letter-spacing: .06em; } + +/* ---- layout ---- */ +.admin { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 100vh; } +.side { + background: linear-gradient(180deg, var(--machine-hi), var(--machine) 160px, var(--machine-lo)); + color: var(--machine-ink); padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; +} +.side :focus-visible { outline-color: var(--focus); } +.side-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; } +.side .logo { height: 30px; } +.side .brand { min-height: 0; } +.side-tag { display: flex; align-items: center; gap: 10px; margin: 0; } +@media (min-width: 960px) { .side .logo { height: 38px; } .side-tag { padding-left: 2px; } } +.login .logo-lg .logo { height: 48px; } +.login .brand { justify-content: center; } +.side-sub { font-size: .82rem; color: var(--machine-muted); font-weight: 600; } +@media (max-width: 459px) { .side-sub { display: none; } } +@media (max-width: 959px) { .side-top { gap: 6px 12px; } } +.side-nav { display: flex; gap: 4px; min-width: 0; overflow-x: auto; scrollbar-width: thin; margin: 0 -16px; padding: 0 16px 4px; scroll-snap-type: x proximity; } +.side-link { + display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 12px; border-radius: 10px; + color: var(--machine-ink); text-decoration: none; font-weight: 600; font-size: .93rem; white-space: nowrap; scroll-snap-align: start; + transition: background-color .15s; +} +.side-link .icon { color: var(--machine-muted); } +.side-link:hover { background: rgba(255,255,255,.08); } +.side-link.is-active { background: var(--gold); color: var(--gold-ink); } +.side-link.is-active .icon { color: var(--gold-ink); } +.side-foot { display: none; } +.side .icon-btn, .side .text-btn { color: var(--machine-ink); border-color: var(--machine-line); } +.side .icon-btn:hover, .side .text-btn:hover { background: rgba(255,255,255,.08); } + +.admin-main { padding: 22px 16px 120px; min-width: 0; outline: none; } +.preview { padding: 0 16px 120px; min-width: 0; } +.has-preview .admin-main { padding-bottom: 8px; } +@media (min-width: 1320px) { .has-preview .admin-main { padding-bottom: 120px; } } + +/* mobile: user controls live at the end of the nav strip */ +@media (max-width: 959px) { + .side { position: sticky; top: 0; z-index: 10; } + .side-foot { display: flex; align-items: center; gap: 8px; } + .side-foot .side-link, .side-foot .side-user { display: none; } + .side-top { flex-wrap: nowrap; } + .side { flex-direction: row; flex-wrap: wrap; align-items: center; } + .side-top { flex: 1; } + .side > * { min-width: 0; } + .side-nav { order: 3; flex: 1 1 100%; width: calc(100% + 32px); max-width: calc(100% + 32px); } + .side-actions { display: flex; gap: 6px; } + .side-actions .text-btn span { display: none; } +} +@media (min-width: 960px) { + .admin { grid-template-columns: 252px minmax(0, 1fr); } + .side { position: sticky; top: 0; height: 100vh; height: 100dvh; padding: 22px 16px; } + .side-top { flex-direction: column; align-items: flex-start; gap: 12px; } + .side-nav { flex-direction: column; overflow: visible; margin: 12px 0 0; padding: 0; } + .side-foot { display: grid; gap: 10px; margin-top: auto; padding-top: 14px; border-top: 1px solid var(--machine-line); } + .side-user { display: grid; padding: 0 12px; } + .side-user-name { font-weight: 700; } + .side-user-id { font-size: .84rem; color: var(--machine-muted); } + .side-actions { display: flex; gap: 8px; padding: 0 4px; } + .admin-main { padding: 34px 32px 120px; } + .preview { grid-column: 2; padding: 0 32px 120px; } +} +@media (min-width: 1320px) { + /* form and preview stay side by side; on very wide screens the spare room goes to the right, not between them */ + .admin.has-preview { grid-template-columns: 252px minmax(0, 924px) 360px; } + .has-preview .preview { grid-column: 3; padding: 34px 24px 40px 0; } + .preview-card { position: sticky; top: 24px; } +} +.admin-main > * { max-width: 860px; } + +.page-head { margin-bottom: 22px; } +.page-head h1 { font-size: 1.6rem; font-weight: 800; font-stretch: 92%; letter-spacing: -.005em; } +.page-head p { color: var(--muted); margin-top: 4px; max-width: 65ch; } +.page-head .page-meta { font-size: .85rem; margin-top: 8px; } + +/* ---- panels ---- */ +.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 18px; margin-bottom: 16px; } +@media (min-width: 640px) { .panel { padding: 22px 24px; } } +.panel-head { margin-bottom: 14px; } +.panel-head h2 { font-size: 1.08rem; font-weight: 750; } +.panel-head p { color: var(--muted); font-size: .9rem; margin-top: 3px; max-width: 70ch; } + +.setting-list { display: grid; } +.setting { + display: grid; grid-template-columns: 1fr; gap: 8px 20px; align-items: center; + padding: 14px 0; border-top: 1px solid var(--line); +} +.setting:first-child { border-top: 0; padding-top: 0; } +.setting label { font-weight: 650; } +.setting-text p { font-size: .85rem; color: var(--muted); margin-top: 2px; } +.setting .input-affix { max-width: 260px; } +@media (min-width: 640px) { + .setting { grid-template-columns: minmax(0, 1fr) 220px; } + .setting .input-affix { max-width: none; } +} + +.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin: 6px 0 22px; } + +/* tier tables */ +.tier-table, .matrix { width: 100%; border-collapse: collapse; } +.tier-table th, .matrix th { text-align: left; font-size: .78rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 0 8px 8px 0; } +.tier-table td, .matrix td, .matrix tbody th { padding: 6px 8px 6px 0; vertical-align: middle; border-top: 1px solid var(--line); } +.matrix tbody th { text-transform: none; letter-spacing: 0; font-size: .9rem; color: var(--ink); font-weight: 600; } +.tier-range { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.tier-from { font-size: .88rem; color: var(--muted); font-weight: 600; white-space: nowrap; min-width: 0; } +@media (min-width: 640px) { .tier-from { flex: 0 0 168px; } } +.tier-max-wrap { flex: 1 1 170px; } +.tier-max-wrap[hidden] { display: none; } +.tier-rate { max-width: 130px; } +.tier-del-cell { width: 52px; text-align: right; padding-right: 0 !important; } +.tier-del { border-color: transparent; color: var(--muted); } +.tier-del:hover:not(:disabled) { color: var(--danger); background: var(--err-bg); } +.tier-add, .cb-add { margin-top: 12px; } +.cb-bank { color: var(--muted); font-weight: 600; white-space: nowrap; } + +.matrix-scroll { overflow-x: auto; margin: 0 -4px; padding: 0 4px; } +.matrix { min-width: 760px; } +.matrix .tier-rate { max-width: 104px; min-width: 88px; } +.matrix tbody th { min-width: 230px; } + +@media (max-width: 639px) { + /* stack tier rows into cards */ + .tier-table thead { display: none; } + .tier-table, .tier-table tbody, .tier-table tr, .tier-table td { display: block; width: 100%; } + .tier-table tr { position: relative; border-top: 1px solid var(--line); padding: 10px 52px 10px 0; } + .tier-table td { border: 0; padding: 4px 0; } + .tier-table td[data-label]::before { content: attr(data-label); display: block; font-size: .76rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 4px; } + .tier-table .tier-del-cell { position: absolute; right: 0; top: 10px; width: auto; } + .tier-rate { max-width: none; } +} + +@media (max-width: 639px) { + /* deposito matrix: one card per nominal band, tenor rates in a small grid */ + .matrix-scroll { overflow: visible; margin: 0; padding: 0; } + .matrix { min-width: 0; } + .matrix thead { display: none; } + .matrix, .matrix tbody { display: block; } + .matrix tr { position: relative; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 12px 0; border-top: 1px solid var(--line); } + .matrix tbody th { grid-column: 1 / -1; min-width: 0; padding: 0 48px 0 0; border: 0; } + .matrix td { display: block; padding: 0; border: 0; } + .matrix td[data-label]::before { content: attr(data-label); display: block; font-size: .74rem; font-weight: 700; color: var(--muted); margin-bottom: 4px; } + .matrix .tier-rate { max-width: none; min-width: 0; } + .matrix .tier-del-cell { position: absolute; top: 8px; right: 0; width: auto; } +} +.check-row { display: flex; flex-wrap: wrap; gap: 8px; } +.check { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 14px; border: 1px solid var(--line); border-radius: 10px; cursor: pointer; font-weight: 600; } +.check:has(input:checked) { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 8%, transparent); } +.check input { width: 18px; height: 18px; accent-color: var(--primary); } + +.panel-danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); } +.panel-danger summary { list-style: none; display: flex; justify-content: space-between; align-items: center; cursor: pointer; min-height: 44px; margin: -10px 0; } +.panel-danger summary::-webkit-details-marker { display: none; } +.panel-danger summary h2 { font-size: 1.02rem; font-weight: 700; color: var(--danger); } +:root[data-theme="dark"] .panel-danger summary h2 { color: var(--err-ink); } +.panel-danger[open] summary { margin-bottom: 12px; } +.panel-danger[open] .chev { transform: rotate(180deg); } +.danger-body { display: grid; gap: 14px; max-width: 520px; } +.danger-body p { color: var(--muted); font-size: .92rem; } +.danger-body .btn { justify-self: start; } + +/* users */ +.user-list { list-style: none; margin: 0; padding: 0; } +.user { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: flex-start; justify-content: space-between; padding: 14px 0; border-top: 1px solid var(--line); } +.user:first-child { border-top: 0; padding-top: 0; } +.user.is-off .user-name { color: var(--muted); } +.user-main { display: grid; min-width: 0; } +.user-name { font-weight: 700; } +.user-meta { font-size: .86rem; color: var(--muted); } +.user-actions { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; } +.badge { font-size: .72rem; font-weight: 700; padding: 1px 8px; border-radius: 999px; background: var(--info-bg); color: var(--info-ink); vertical-align: 2px; } +.badge-off { background: var(--surface-2); color: var(--muted); border: 1px solid var(--line); } +.inline-details > summary { list-style: none; } +.inline-details > summary::-webkit-details-marker { display: none; } +.inline-form { display: grid; gap: 10px; margin-top: 10px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); min-width: min(300px, 80vw); } +.two-col { display: grid; gap: 16px; } +@media (min-width: 900px) { .two-col { grid-template-columns: 1fr 1fr; } } +.two-col .panel { margin: 0; } +.inline-buttons { display: flex; flex-wrap: wrap; gap: 8px; } +.stack-form { display: grid; gap: 14px; } +.stack-form .btn { justify-self: start; } + +/* log */ +.log-list { list-style: none; margin: 0; padding: 0; } +.log { padding: 14px 0; border-top: 1px solid var(--line); } +.log:first-child { border-top: 0; padding-top: 0; } +.log-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 16px; } +.log-what { font-weight: 650; } +.log-meta { color: var(--muted); font-size: .86rem; } +.log-diff { margin: 8px 0 0; padding: 10px 12px; list-style: none; background: var(--surface-2); border-radius: 8px; font-size: .86rem; display: grid; gap: 6px; font-family: var(--font-tape); overflow-wrap: anywhere; } +.log-diff del { color: var(--danger); } +.log-diff ins { color: var(--good); text-decoration: none; } +:root[data-theme="dark"] .log-diff del { color: var(--err-ink); } +:root[data-theme="dark"] .log-diff ins { color: var(--ok-ink); } +.pager { display: flex; justify-content: center; align-items: center; gap: 14px; margin-top: 16px; color: var(--muted); font-size: .9rem; } +.empty { color: var(--muted); } + +/* preview */ +.preview-card { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 18px; } +.preview-card h2 { font-size: 1.05rem; font-weight: 750; } +.preview-card > .muted { font-size: .86rem; margin-top: 2px; } +.pv-inputs { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; margin: 14px 0; } +@media (min-width: 560px) and (max-width: 1319px) { .pv-inputs { grid-template-columns: 1fr 1fr; } } +.pv-table { width: 100%; border-collapse: collapse; font-size: .88rem; } +.pv-table th, .pv-table td { padding: 7px 0; border-top: 1px solid var(--line); text-align: right; white-space: nowrap; } +.pv-table th[scope=row] { text-align: left; font-weight: 600; color: var(--muted); white-space: normal; } +.pv-table thead th { border-top: 0; font-size: .74rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 700; } +.pv-table td { padding-left: 10px; font-variant-numeric: tabular-nums; } +.pv-table tr.is-changed th { color: var(--ink); } +.pv-table td.up { color: var(--good); font-weight: 700; } +/* each row: label on top, saved | form values below at half width each, so large amounts fit the narrow card */ +.pv-table thead tr, .pv-table tbody tr { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 10px; border-top: 1px solid var(--line); padding: 7px 0; } +.pv-table thead tr { border-top: 0; padding-top: 0; } +.pv-table th, .pv-table td { border-top: 0; padding: 0; min-width: 0; overflow-wrap: anywhere; } +.pv-table thead th:first-child { display: none; } +.pv-table th[scope=row] { grid-column: 1 / -1; margin-bottom: 2px; } +.pv-table td { font-size: .84rem; } +.pv-table td.down { color: var(--danger); font-weight: 700; } +:root[data-theme="dark"] .pv-table td.up { color: var(--ok-ink); } +:root[data-theme="dark"] .pv-table td.down { color: var(--err-ink); } + +/* save bar */ +.savebar { + position: fixed; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 30; + width: min(720px, calc(100vw - 24px)); + display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; + padding: 10px 12px 10px 16px; border-radius: 14px; + background: var(--machine); color: var(--machine-ink); + box-shadow: 0 18px 40px -14px rgba(4, 18, 48, .6); + animation: rise .25s var(--ease-out); +} +.savebar[hidden] { display: none; } +.savebar > span { display: inline-flex; gap: 8px; align-items: center; font-weight: 600; font-size: .92rem; } +.savebar > div { display: flex; gap: 8px; margin-left: auto; } +.savebar .btn-ghost { color: var(--machine-ink); border-color: var(--machine-line); } +.savebar .btn-ghost:hover { background: rgba(255,255,255,.08); } +.savebar .btn-primary { background: var(--gold); color: var(--gold-ink); } +.savebar .btn-primary:hover { background: var(--gold-hi); } +.savebar :focus-visible { outline-color: var(--focus); } +@media (min-width: 960px) { .savebar { left: calc(50% + 126px); width: min(720px, calc(100vw - 300px)); } } +@keyframes rise { from { transform: translate(-50%, 20px); opacity: 0; } } +@media (min-width: 1320px) { .has-preview ~ .savebar { left: calc(252px + 462px); width: 720px; } } diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..4c9e142 --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,302 @@ +/* Calculator page: the machine (inputs + display) and the paper tape (results). */ + +.desk { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 20px; + max-width: 1200px; + margin: 0 auto; + padding: 4px max(16px, env(safe-area-inset-right)) 48px max(16px, env(safe-area-inset-left)); +} +@media (min-width: 900px) { + .desk { grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); gap: 28px; align-items: start; } + .machine { position: sticky; top: 16px; } +} +@media (min-width: 1180px) { + .desk { grid-template-columns: 460px minmax(0, 1fr); gap: 40px; } +} + +/* ---- machine ---- */ +.machine { + background: linear-gradient(180deg, var(--machine-hi) 0%, var(--machine) 120px, var(--machine-lo) 100%); + color: var(--machine-ink); + border-radius: var(--radius-l); + box-shadow: var(--shadow-machine); + padding: 18px 18px 20px; +} +.machine :focus-visible { outline-color: var(--focus); } +@media (min-width: 480px) { .machine { padding: 22px 24px 24px; } } +@media (max-width: 299px) { .machine { padding: 14px 12px 16px; } .tape { padding-left: 12px; padding-right: 12px; } } + +.machine-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 12px; margin-bottom: 14px; } +.machine-head h1 { font-size: 1.15rem; font-weight: 750; font-stretch: 92%; letter-spacing: .005em; } +.machine-head p { font-size: .85rem; color: var(--machine-muted); } + +/* display */ +.display { + background: var(--display); + border-radius: 14px; + padding: 14px 16px 14px; + box-shadow: inset 0 3px 10px rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,255,255,.04), 0 1px 0 rgba(255,255,255,.1); + color: var(--display-ink); + outline: none; +} +.display-top { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 4px 10px; min-height: 24px; } +.display-label { font-size: .8rem; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--display-dim); } +.display-flag { + font-size: .75rem; font-weight: 650; padding: 2px 8px; border-radius: 999px; + border: 1px solid var(--display-line); color: var(--display-dim); white-space: nowrap; +} +.display-flag:empty { display: none; } +.display-value { + display: block; + font-size: clamp(2.1rem, 9vw, 2.9rem); + font-weight: 800; + font-stretch: 78%; + line-height: 1.05; + letter-spacing: -.01em; + text-align: right; + margin: 6px 0 10px; + white-space: nowrap; /* never split a number; app.js shrinks the size to fit */ + overflow: hidden; + transition: color .2s; +} +.display.is-off .display-value { color: var(--display-dim); opacity: .7; } +.display-sub { + display: flex; flex-wrap: wrap; gap: 8px 18px; + margin: 0; padding-top: 10px; border-top: 1px dashed var(--display-line); +} +.display-sub div { flex: 1 0 auto; min-width: 0; } /* grow, never shrink below the number */ +.display-sub dt { font-size: .72rem; color: var(--display-dim); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; } +.display-sub dd { margin: 2px 0 0; font-weight: 700; font-size: .98rem; font-stretch: 88%; white-space: nowrap; } +.display-note { + display: flex; gap: 8px; align-items: flex-start; + margin-top: 12px; padding: 9px 11px; border-radius: 9px; + background: rgba(255, 230, 160, .1); color: var(--display-ink); font-size: .86rem; line-height: 1.45; +} +.display-note[hidden] { display: none; } +.display-note .icon { margin-top: 2px; color: var(--gold); } + +/* inputs */ +.keys { margin-top: 18px; display: grid; gap: 18px; } +.field { margin: 0; padding: 0; border: 0; min-width: 0; } +.field > label, .field > legend { + display: block; padding: 0; margin-bottom: 8px; + font-size: .82rem; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--machine-muted); +} +.entry { + display: flex; align-items: center; gap: 8px; + background: var(--display); border-radius: 12px; padding: 0 14px; + box-shadow: inset 0 2px 6px rgba(0,0,0,.5), 0 1px 0 rgba(255,255,255,.08); + border: 2px solid transparent; transition: border-color .15s; +} +.entry:focus-within { border-color: var(--gold); } +.entry.is-invalid { border-color: #ff8a7a; } +.entry-prefix { font-weight: 700; color: var(--display-dim); font-size: 1.1rem; } +.entry input { + flex: 1; min-width: 0; height: 56px; border: 0; background: transparent; outline: none; + color: var(--display-ink); caret-color: var(--gold); + font-size: 1.55rem; font-weight: 750; font-stretch: 85%; text-align: right; letter-spacing: .01em; +} +.help { margin-top: 7px; font-size: .84rem; color: var(--machine-muted); } +.field-error { margin-top: 7px; font-size: .86rem; color: #ffb4a8; font-weight: 600; } +.field-error[hidden] { display: none; } + +.quick { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 10px; } +@media (min-width: 360px) { .quick { grid-template-columns: repeat(6, 1fr); } } +.chip { + min-height: 36px; padding: 0 4px; border-radius: 8px; white-space: nowrap; + border: 1px solid var(--machine-line); background: rgba(255,255,255,.04); color: var(--machine-ink); + font-size: .86rem; font-weight: 600; transition: background-color .15s, border-color .15s; +} +.chip:hover { background: rgba(255,255,255,.1); border-color: var(--machine-muted); } +.chip[aria-pressed="true"] { background: rgba(242,193,78,.16); border-color: var(--gold); color: var(--gold-hi); } +@media (pointer: coarse) { .chip { min-height: 44px; } } + +/* segmented keys */ +.segmented { display: grid; gap: 6px; } +.seg-tenor { grid-template-columns: repeat(auto-fit, minmax(48px, 1fr)); } +.seg-cust { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); } +.seg { position: relative; display: grid; } +.seg input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; } +.seg-face { + display: flex; flex-direction: column; align-items: center; justify-content: center; + min-height: 52px; padding: 6px 4px 5px; border-radius: 10px; + background: var(--key); color: var(--key-ink); box-shadow: inset 0 -3px 0 var(--key-edge); + font-weight: 650; text-align: center; line-height: 1.15; user-select: none; + transition: background-color .15s, color .15s, transform .08s, box-shadow .08s; +} +.seg-cust .seg-face { flex-direction: row; min-height: 48px; font-size: .95rem; } +.seg-num { font-size: 1.2rem; font-weight: 800; font-stretch: 85%; } +.seg-unit { font-size: .7rem; font-weight: 600; opacity: .8; } +.seg-tag { font-size: .62rem; font-weight: 600; opacity: .75; margin-top: 1px; } +.seg:hover .seg-face { background: var(--key-hi); } +.seg input:checked + .seg-face { + background: var(--gold); color: var(--gold-ink); box-shadow: inset 0 -2px 0 var(--gold-edge); + transform: translateY(1px); +} +.seg input:focus-visible + .seg-face { outline: 3px solid var(--focus); outline-offset: 2px; } +.seg input:active + .seg-face { transform: translateY(2px); box-shadow: inset 0 -1px 0 var(--key-edge); } + +.machine-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; padding-top: 4px; } +.machine-actions .key { min-width: 0; padding: 0 10px; text-align: center; line-height: 1.2; } +@media (max-width: 279px) { .machine-actions { grid-template-columns: minmax(0, 1fr); } } +.machine-actions .key-gold { grid-column: 1 / -1; min-height: 52px; font-size: 1rem; } +.toast { min-height: 1.2em; font-size: .86rem; color: var(--gold-hi); text-align: right; margin-top: -6px; } +.model-note { font-size: .82rem; line-height: 1.4; padding: 8px 10px; border-radius: 8px; background: var(--warn-bg); color: #3b2600; border: 1px solid #e7c271; } +.tape-verify { font-weight: 600; color: var(--paper-ink, inherit); } + +/* ---- tape ---- */ +.tape-wrap { position: relative; min-width: 0; max-width: 720px; width: 100%; justify-self: center; } +@media (min-width: 900px) { .tape-wrap { justify-self: start; } } +.tape-slot { + height: 14px; margin: 0 -6px; border-radius: 8px 8px 4px 4px; + background: linear-gradient(180deg, var(--machine-lo), var(--machine)); + box-shadow: inset 0 -4px 6px rgba(0,0,0,.45); +} +.tape { + --zig: 9px; + position: relative; + margin: -6px 6px 0; + background: var(--paper); + color: var(--paper-ink); + padding: 22px clamp(16px, 4vw, 30px) calc(26px + var(--zig)); + box-shadow: var(--shadow-paper); + /* torn zig-zag bottom edge */ + -webkit-mask: linear-gradient(#000 0 0) top / 100% calc(100% - var(--zig)) no-repeat, + conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) bottom / calc(var(--zig) * 2) var(--zig) repeat-x; + mask: linear-gradient(#000 0 0) top / 100% calc(100% - var(--zig)) no-repeat, + conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) bottom / calc(var(--zig) * 2) var(--zig) repeat-x; + font-family: var(--font-tape); + font-size: .86rem; + line-height: 1.55; +} +.tape ::selection { background: var(--paper-hilite); color: var(--paper-ink); } +.tape :focus-visible { outline-color: var(--focus-on-light); } +.tape.is-feeding { animation: feed .32s var(--ease-out); } +@keyframes feed { from { transform: translateY(-10px); } to { transform: none; } } + +.tape h2, .tape h3 { font-family: var(--font-ui); } +.tape-head { text-align: center; padding-bottom: 14px; border-bottom: 1px dashed var(--paper-rule); } +.tape-logo { justify-content: center; margin-bottom: 12px; } +.tape-logo .logo { height: 34px; } +.tape-head h2 { font-size: 1.08rem; font-weight: 800; font-stretch: 90%; letter-spacing: .04em; text-transform: uppercase; color: var(--paper-accent); } +.tape-meta { color: var(--paper-muted); font-size: .8rem; margin-top: 2px; } +.lines-input { margin-top: 12px; text-align: left; } + +.tape-sec { padding: 16px 0; border-bottom: 1px dashed var(--paper-rule); } +.tape-sec h3 { font-size: .95rem; font-weight: 750; color: var(--paper-ink); margin-bottom: 10px; } + +/* label .......... value lines */ +.lines { margin: 0; display: grid; gap: 4px; } +/* receipt row: the value always stays in the right column on the same row; + when space runs out the label wraps and the value aligns with its last line */ +.lines div { display: flex; flex-wrap: nowrap; align-items: flex-end; column-gap: 8px; min-width: 0; } +.lines dt { color: var(--paper-muted); flex: 0 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.4; order: 0; } +.lines dd { order: 2; flex: none; margin: 0 0 0 auto; text-align: right; white-space: nowrap; font-weight: 600; color: var(--paper-ink); line-height: 1.4; } +/* dotted leader between label and value, like a printed receipt */ +.lines div::after { + content: ""; order: 1; flex: 1 1 10px; min-width: 10px; align-self: flex-end; + border-bottom: 1px dotted var(--paper-rule); margin-bottom: .45em; +} +.lines .is-strong::after, .lines .is-group::after { border-bottom-color: transparent; } +.lines .is-strong dt, .lines .is-strong dd { color: var(--paper-ink); font-weight: 800; } +.lines .is-strong { padding: 6px 0; border-top: 1px solid var(--paper-ink); border-bottom: 1px solid var(--paper-ink); margin: 4px 0; font-size: 1.02rem; } +.lines .is-sub dt { padding-left: 14px; } +.lines .is-neg dd { color: var(--bad); } +.lines .is-group { margin-top: 10px; } +.lines .is-group dt { color: var(--paper-ink); font-weight: 700; font-family: var(--font-ui); } +.lines-total { font-size: .92rem; } +@media (max-width: 420px) { + .tape { font-size: .8rem; } + .lines-total { font-size: .86rem; } + .lines .is-strong { font-size: .94rem; } +} +.lines dt .formula { display: block; font-size: .74rem; color: var(--paper-muted); opacity: .9; } + +/* comparison rows */ +.cmp { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } +.cmp-row { + padding: 11px 12px 12px; border-radius: 10px; border: 1px solid var(--paper-rule); + background: color-mix(in srgb, var(--paper) 92%, var(--paper-rule)); +} +.cmp-row.is-best { border-color: var(--paper-accent); background: var(--paper); box-shadow: inset 0 0 0 1px var(--paper-accent); } +.cmp-row.is-dep { border-style: dashed; background: transparent; } +.cmp-top { display: flex; align-items: baseline; justify-content: space-between; gap: 6px 12px; flex-wrap: wrap; } +.cmp-name { font-family: var(--font-ui); font-weight: 750; font-size: .98rem; display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.cmp-tag { + font-family: var(--font-ui); font-size: .7rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; + padding: 1px 7px; border-radius: 999px; background: var(--paper-accent); color: #fff; +} +.cmp-row.is-dep .cmp-tag { background: transparent; color: var(--paper-muted); border: 1px solid var(--paper-rule); } +.cmp-total { font-weight: 800; font-size: clamp(.86rem, 4.2vw, 1.08rem); margin-left: auto; white-space: nowrap; } +.cmp-bar { height: 8px; border-radius: 4px; background: color-mix(in srgb, var(--paper-rule) 45%, transparent); margin: 8px 0 9px; overflow: hidden; display: flex; } +.cmp-bar i { display: block; height: 100%; background: var(--bar); } +.cmp-bar i + i { background: var(--gold); } +.cmp-row.is-dep .cmp-bar i { background: var(--bar-dep); } +.cmp-meta { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 0; font-size: .82rem; } +.cmp-meta div { display: flex; flex-direction: column; gap: 0; flex: 1 0 auto; min-width: 84px; } +.cmp-meta dt { color: var(--paper-muted); font-size: .72rem; } +.cmp-meta dd { margin: 0; font-weight: 600; white-space: nowrap; } +.cmp-diff { margin-top: 6px; font-size: .8rem; font-weight: 700; } +.cmp-diff.up { color: var(--good); } +.cmp-diff.down { color: var(--bad); } +.legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: .76rem; color: var(--paper-muted); margin: -2px 0 10px; } +.legend span { display: inline-flex; align-items: center; gap: 6px; } +.legend i { width: 10px; height: 10px; border-radius: 2px; background: var(--bar); } +.legend i.cb { background: var(--gold); } + +.tape-note { font-size: .76rem; color: var(--paper-muted); margin-top: 10px; line-height: 1.5; } + +.tape-details summary { + list-style: none; display: flex; align-items: center; justify-content: space-between; gap: 10px; + cursor: pointer; min-height: 44px; margin: -8px 0; +} +.tape-details summary::-webkit-details-marker { display: none; } +.tape-details summary h3 { margin: 0; display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.tape-details summary .chev { transition: transform .2s var(--ease-out); color: var(--paper-muted); } +.tape-details[open] summary .chev { transform: rotate(180deg); } +.tape-details[open] summary { margin-bottom: 8px; } +.tape-details summary:hover h3 { text-decoration: underline; text-underline-offset: .25em; } + +.calc-block + .calc-block { margin-top: 14px; } +.calc-block h4 { font-family: var(--font-ui); font-size: .85rem; font-weight: 750; margin: 0 0 4px; color: var(--paper-accent); } + +.tape-foot { padding-top: 14px; text-align: center; color: var(--paper-muted); font-size: .76rem; display: grid; gap: 2px; } + +/* mini display (phones, when the main display scrolled away) */ +.mini-display { + position: fixed; left: 12px; right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); z-index: 20; + display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; + padding: 10px 14px; border-radius: 14px; background: var(--display); color: var(--display-ink); + box-shadow: 0 12px 30px -10px rgba(0,0,0,.55); + transform: translateY(140%); transition: transform .25s var(--ease-out); +} +.mini-display.is-shown { transform: none; } +.mini-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--display-dim); font-weight: 650; } +.mini-value { font-weight: 800; font-size: 1.2rem; font-stretch: 82%; } +.mini-equiv { margin-left: auto; font-size: .85rem; color: var(--display-dim); font-weight: 600; } +@media (min-width: 900px) { .mini-display { display: none; } } +@media (max-width: 899px) { .page-calc { padding-bottom: 76px; } } + +/* error page */ +.desk-error { display: block; } +.panel-error { + display: flex; gap: 14px; max-width: 640px; margin: 40px auto; padding: 20px; border-radius: var(--radius-m); + background: var(--warn-bg); color: #3b2600; border: 1px solid #e7c271; +} +.panel-error .icon { width: 24px; height: 24px; color: var(--warn); margin-top: 2px; } +.panel-error h1 { font-size: 1.15rem; margin-bottom: 6px; } + +/* ---- print: only the tape ---- */ +@media print { + @page { margin: 12mm; } + body { background: #fff; } + .topbar, .machine, .tape-slot, .mini-display, .skip-link { display: none !important; } + .desk { display: block; padding: 0; } + .tape-wrap { max-width: none; } + .tape { margin: 0; box-shadow: none; -webkit-mask: none; mask: none; background: #fff; color: #000; padding: 0; font-size: 10.5pt; } + .tape-details summary .chev { display: none; } + .tape-logo .logo { height: 12mm; } + .cmp-row { break-inside: avoid; } +} diff --git a/assets/css/base.css b/assets/css/base.css new file mode 100644 index 0000000..005e446 --- /dev/null +++ b/assets/css/base.css @@ -0,0 +1,220 @@ +/* SERUBI base: tokens, fonts, reset, shared controls. */ +@font-face { + font-family: "Archivo"; + src: url("../fonts/archivo-var.woff2") format("woff2"); + font-weight: 100 900; + font-stretch: 62% 125%; + font-display: swap; +} +@font-face { + font-family: "JetBrains Mono"; + src: url("../fonts/jetbrains-mono-var.woff2") format("woff2"); + font-weight: 400 700; + font-display: swap; +} + +:root { + color-scheme: light; + --font-ui: "Archivo", "Segoe UI", system-ui, -apple-system, sans-serif; + --font-tape: "JetBrains Mono", ui-monospace, "Cascadia Mono", Consolas, monospace; + + /* desk (page ground) */ + --desk: #e7ebf2; + --desk-2: #dde3ec; + --desk-ink: #14223b; + --desk-muted: #4b576c; + --desk-line: #c6cfdc; + + /* machine body */ + --machine: #0b2e66; + --machine-hi: #154290; + --machine-lo: #072153; + --machine-ink: #eef3fb; + --machine-muted: #aebfdb; + --machine-line: rgba(174, 191, 219, .22); + --key: #143c7c; + --key-hi: #1c4a93; + --key-edge: #061a40; + --key-ink: #e9f0fc; + + /* gold (Bank Papua accent) */ + --gold: #f2c14e; + --gold-hi: #f8d377; + --gold-edge: #a87b14; + --gold-ink: #241800; + + /* display */ + --display: #04132f; + --display-ink: #ffe6a0; + --display-dim: #c9b98c; + --display-line: rgba(255, 230, 160, .16); + + /* paper */ + --paper: #fffef8; + --paper-ink: #1a2130; + --paper-muted: #545d70; + --paper-rule: #c3c7cf; + --paper-accent: #0b2e66; + --paper-hilite: #fff2c4; + --bar: #0b2e66; + --bar-dep: #9aa3b3; + + /* states */ + --good: #0a7140; + --bad: #b42318; + --warn: #7a5000; + --warn-bg: #fff3d6; + --focus: #f2c14e; + --focus-on-light: #0b57d0; + + --radius-s: 8px; + --radius-m: 12px; + --radius-l: 22px; + --ease-out: cubic-bezier(.16, 1, .3, 1); + --shadow-machine: 0 1px 0 rgba(255,255,255,.14) inset, 0 -2px 0 rgba(0,0,0,.25) inset, 0 28px 50px -24px rgba(4, 18, 48, .6), 0 3px 8px rgba(4, 18, 48, .18); + --shadow-paper: 0 18px 30px -18px rgba(20, 34, 59, .45), 0 2px 4px rgba(20, 34, 59, .08); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --desk: #070c17; + --desk-2: #0c1424; + --desk-ink: #e4e9f3; + --desk-muted: #9ba7bc; + --desk-line: #1f2a40; + --machine: #0c2a5c; + --machine-hi: #12387a; + --machine-lo: #071a40; + --key: #11346d; + --key-hi: #184083; + --display: #020a1c; + --paper: #ebe7db; + --paper-ink: #191f2b; + --paper-muted: #4c5464; + --paper-rule: #b4b1a6; + --paper-hilite: #f4e3ad; + --shadow-machine: 0 1px 0 rgba(255,255,255,.08) inset, 0 -2px 0 rgba(0,0,0,.3) inset, 0 28px 50px -24px rgba(0,0,0,.8), 0 3px 8px rgba(0,0,0,.4); + --shadow-paper: 0 18px 30px -18px rgba(0,0,0,.7), 0 2px 4px rgba(0,0,0,.3); + } +} +:root[data-theme="dark"] { + color-scheme: dark; + --desk: #070c17; + --desk-2: #0c1424; + --desk-ink: #e4e9f3; + --desk-muted: #9ba7bc; + --desk-line: #1f2a40; + --machine: #0c2a5c; + --machine-hi: #12387a; + --machine-lo: #071a40; + --key: #11346d; + --key-hi: #184083; + --display: #020a1c; + --paper: #ebe7db; + --paper-ink: #191f2b; + --paper-muted: #4c5464; + --paper-rule: #b4b1a6; + --paper-hilite: #f4e3ad; + --shadow-machine: 0 1px 0 rgba(255,255,255,.08) inset, 0 -2px 0 rgba(0,0,0,.3) inset, 0 28px 50px -24px rgba(0,0,0,.8), 0 3px 8px rgba(0,0,0,.4); + --shadow-paper: 0 18px 30px -18px rgba(0,0,0,.7), 0 2px 4px rgba(0,0,0,.3); +} + +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } +body { + margin: 0; + min-height: 100vh; + min-height: 100dvh; + background: var(--desk); + color: var(--desk-ink); + font-family: var(--font-ui); + font-size: 1rem; + line-height: 1.5; + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; +} +h1, h2, h3 { line-height: 1.2; text-wrap: balance; margin: 0; } +p { margin: 0; } +button, input, select, textarea { font: inherit; color: inherit; } +button { cursor: pointer; } +a { color: inherit; text-underline-offset: .2em; } +::selection { background: var(--gold); color: var(--gold-ink); } +:focus-visible { outline: 3px solid var(--focus-on-light); outline-offset: 2px; border-radius: 4px; } +code { font-family: var(--font-tape); font-size: .9em; } + +.icon { width: 1.15em; height: 1.15em; flex: none; } + +.skip-link { + position: absolute; left: 12px; top: -60px; z-index: 50; + background: var(--gold); color: var(--gold-ink); padding: 10px 14px; border-radius: var(--radius-s); + font-weight: 700; text-decoration: none; +} +.skip-link:focus { top: 12px; } + +.visually-hidden { + position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} + +/* ---- Bank Papua logo ---- */ +.logo-wrap { display: inline-flex; line-height: 0; } +.logo { display: block; height: 36px; width: auto; max-width: 100%; } +.logo-auto .logo-white { display: none; } +:root[data-theme="dark"] .logo-auto .logo-color { display: none; } +:root[data-theme="dark"] .logo-auto .logo-white { display: block; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .logo-auto .logo-color { display: none; } + :root:not([data-theme="light"]) .logo-auto .logo-white { display: block; } +} +@media (max-width: 419px) { .topbar .logo { height: 29px; } .topbar .brand { gap: 4px 8px; } } + +/* ---- top bar ---- */ +.topbar { + display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; + max-width: 1200px; margin: 0 auto; + padding: max(14px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) 14px max(16px, env(safe-area-inset-left)); +} +.brand { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; text-decoration: none; min-height: 44px; align-items: center; } +.brand-prog { + font-weight: 700; font-size: .78rem; letter-spacing: .14em; + padding: 3px 8px 2px; border-radius: 5px; background: var(--gold); color: var(--gold-ink); +} +.topbar-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; } +@media (max-width: 479px) { .topbar-actions .text-btn span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .topbar-actions .text-btn { padding: 0; } } +.icon-btn, .text-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + min-width: 44px; min-height: 44px; padding: 0 12px; + border: 1px solid var(--desk-line); background: transparent; color: var(--desk-ink); + border-radius: 10px; text-decoration: none; font-weight: 600; font-size: .92rem; + transition: background-color .18s, border-color .18s; +} +.icon-btn { padding: 0; } +.icon-btn:hover, .text-btn:hover { background: var(--desk-2); border-color: var(--desk-muted); } +.icon-sun { display: none; } +:root[data-theme="dark"] .icon-sun { display: block; } +:root[data-theme="dark"] .icon-moon { display: none; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .icon-sun { display: block; } + :root:not([data-theme="light"]) .icon-moon { display: none; } +} + +/* ---- keys (machine buttons) ---- */ +.key { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + min-height: 48px; padding: 0 16px; border-radius: 10px; + border: 0; box-shadow: inset 0 -3px 0 var(--key-edge); + background: var(--key); color: var(--key-ink); font-weight: 650; font-size: .95rem; + transition: background-color .15s, transform .08s, box-shadow .08s; + -webkit-tap-highlight-color: transparent; +} +.key:hover { background: var(--key-hi); } +.key:active { transform: translateY(2px); box-shadow: inset 0 -1px 0 var(--key-edge); } +.key-gold { background: var(--gold); color: var(--gold-ink); box-shadow: inset 0 -3px 0 var(--gold-edge); } +.key-gold:active { box-shadow: inset 0 -1px 0 var(--gold-edge); } +.key-gold:hover { background: var(--gold-hi); } +.key:disabled { opacity: .55; cursor: not-allowed; transform: none; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; } +} diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..c98d0f7 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1 @@ + diff --git a/assets/fonts/archivo-var.woff2 b/assets/fonts/archivo-var.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e2448d0561ffdb0b0974d063a7cdbb712f41ad49 GIT binary patch literal 90104 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 literal 0 HcmV?d00001 diff --git a/assets/img/logo-bank-papua-white.png b/assets/img/logo-bank-papua-white.png new file mode 100644 index 0000000000000000000000000000000000000000..97fdfb7ce12d8c3830a2d4589f1375e2169b1eac GIT binary patch literal 19817 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 Ubj+`st^fc407*qoM6N<$g0tAix&QzG literal 0 HcmV?d00001 diff --git a/assets/img/logo-bank-papua.png b/assets/img/logo-bank-papua.png new file mode 100644 index 0000000000000000000000000000000000000000..f831c146f723cbdb5ea7463280b4d7d9dab880b2 GIT binary patch literal 12500 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 eGB9v>AZTT2XJ~XyRFnh&0000 1) { del.closest('.tier-row').remove(); refreshTiers(editor); markDirty(); } + } else if (add) { + var rowsA = tierRows(editor); + var lastRow = rowsA[rowsA.length - 1]; + var copy = lastRow.cloneNode(true); + copy.querySelectorAll('input').forEach(function (i) { i.value = ''; i.removeAttribute('aria-invalid'); }); + // the new row goes before the open-ended last row, keeping "di atas" at the bottom + lastRow.parentNode.insertBefore(copy, lastRow); + copy.querySelectorAll('.t-rate').forEach(function (inp, k) { + var src = lastRow.querySelectorAll('.t-rate')[k]; + inp.value = src ? src.value : ''; + }); + refreshTiers(editor); + var first = copy.querySelector('.t-max'); + if (first) first.focus(); + markDirty(); + } + }); + }); + + // ---- cashback tiers ---- + var cbTable = $('cbTable'); + function refreshCb() { + var rows = cbTable.querySelectorAll('tbody .cb-row'); + rows.forEach(function (row, i) { + var share = F.parsePct(row.querySelector('.cb-share').value); + row.querySelector('.cb-bank').textContent = share === null || isNaN(share) ? '—' : F.pctInput(1 - share) + '%'; + row.querySelectorAll('input[name]').forEach(function (inp) { inp.name = inp.name.replace(/^cb\[\d+\]/, 'cb[' + i + ']'); }); + row.querySelector('.cb-del').disabled = rows.length === 1; + }); + } + if (cbTable) { + refreshCb(); + cbTable.addEventListener('input', refreshCb); + cbTable.addEventListener('click', function (ev) { + var del = ev.target.closest('.cb-del'); + if (del && cbTable.querySelectorAll('tbody .cb-row').length > 1) { del.closest('.cb-row').remove(); refreshCb(); markDirty(); } + }); + document.querySelector('.cb-add').addEventListener('click', function () { + var rows = cbTable.querySelectorAll('tbody .cb-row'); + var copy = rows[rows.length - 1].cloneNode(true); + copy.querySelectorAll('input').forEach(function (i) { i.value = ''; }); + cbTable.querySelector('tbody').appendChild(copy); + refreshCb(); + copy.querySelector('.cb-min').focus(); + markDirty(); + }); + } + + // ---- unsaved changes ---- + var dirty = false; + var savebar = $('savebar'); + function markDirty() { + if (!form) return; + dirty = true; + savebar.hidden = false; + document.body.classList.add('has-savebar'); + preview(); + } + if (form) { + form.addEventListener('input', markDirty); + form.addEventListener('change', markDirty); + form.addEventListener('submit', function () { + dirty = false; + form.querySelectorAll('button[type=submit]').forEach(function (b) { b.disabled = true; b.setAttribute('aria-busy', 'true'); }); + $('btnSave').disabled = true; + }); + $('btnSave').addEventListener('click', function () { form.requestSubmit ? form.requestSubmit() : form.submit(); }); + $('btnDiscard').addEventListener('click', function () { dirty = false; location.href = location.pathname + location.search; }); + window.addEventListener('beforeunload', function (ev) { if (dirty) { ev.preventDefault(); ev.returnValue = ''; } }); + document.addEventListener('serubi:dirty', markDirty); + } + + // ---- draft params from the form ---- + function readTierEditor(editor, rateSelector) { + return tierRows(editor).map(function (row, i, all) { + return { max: i === all.length - 1 ? null : F.parseIdr(row.querySelector('.t-max').value), rate: F.parsePct(row.querySelector(rateSelector).value) }; + }); + } + + function draftParams() { + var p = clone(saved); + if (!form) return p; + var kind = form.dataset.kind; + var bad = false; + if (kind === 'settings') { + form.querySelectorAll('[data-setting]').forEach(function (inp) { + var u = inp.dataset.unit, v; + if (u === 'pct') v = F.parsePct(inp.value); + else if (u === 'idr') v = F.parseIdr(inp.value); + else v = parseFloat(String(inp.value).replace(',', '.')); + if (v === null || isNaN(v)) bad = true; else p.settings[inp.dataset.setting] = v; + }); + } else if (kind === 'products') { + form.querySelectorAll('[data-tiers]').forEach(function (ed) { p.productRates[ed.dataset.tiers] = readTierEditor(ed, '.t-rate'); }); + } else if (kind === 'deposito') { + var panel = $('depoMatrix').closest('.panel'); + p.tenors.forEach(function (t) { p.depositoRates[t] = readTierEditor(panel, '.t-rate[data-tenor="' + t + '"]'); }); + } else if (kind === 'cashback') { + p.cashbackTiers = Array.prototype.map.call(cbTable.querySelectorAll('tbody .cb-row'), function (row) { + return { min: F.parseIdr(row.querySelector('.cb-min').value), nasabah: F.parsePct(row.querySelector('.cb-share').value) }; + }).filter(function (t) { return t.min !== null && t.nasabah !== null && !isNaN(t.nasabah); }); + p.cashbackTenors = Array.prototype.filter.call(form.querySelectorAll('[data-cbtenor]'), function (c) { return c.checked; }) + .map(function (c) { return Number(c.dataset.cbtenor); }); + } + return bad ? null : p; + } + + // ---- warning: a higher nominal tier with a lower rate is usually a typing mistake ---- + var PRODUCT_NAMES = { giro: 'Giro Umum', simanja: 'SIMANJA', simpeda: 'SIMPEDA' }; + function descending(tiers) { + for (var i = 1; i < (tiers || []).length; i++) { + if (Number(tiers[i].rate) < Number(tiers[i - 1].rate)) return i; + } + return 0; + } + function tierWarnings(p) { + var out = []; + Object.keys(PRODUCT_NAMES).forEach(function (k) { + var i = descending(p.productRates[k]); + if (i) out.push('Rate ' + PRODUCT_NAMES[k] + ' baris ' + (i + 1) + ' lebih kecil dari baris ' + i + '.'); + }); + p.tenors.forEach(function (t) { + var i = descending(p.depositoRates[t]); + if (i) out.push('Rate deposito ' + t + ' bulan baris ' + (i + 1) + ' lebih kecil dari baris ' + i + '.'); + }); + return out; + } + + // ---- preview ---- + var pvRows = $('pvRows'); + function preview() { + if (!pvRows) return; + var n = F.parseIdr($('pvNominal').value) || 0; + var t = Number($('pvTenor').value); + var input = { nominal: n, tenor: t, customerType: 'perorangan' }; + var a = E.calculate(saved, input); + var dp = draftParams(); + var b = dp ? E.calculate(dp, input) : null; + var by = function (r, k) { return r.comparison.filter(function (c) { return c.key === k; })[0]; }; + var rows = [ + ['Cashback neto', function (r) { return r.cashback.net; }, F.rp], + ['Setara p.a.', function (r) { return r.cashback.equiv; }, F.pct], + ['Total Giro', function (r) { return by(r, 'giro').total; }, F.rp], + ['Total SIMANJA', function (r) { return by(r, 'simanja').total; }, F.rp], + ['Total SIMPEDA', function (r) { return by(r, 'simpeda').total; }, F.rp], + ['Deposito neto', function (r) { return r.deposito.net; }, F.rp], + ['Rate deposito', function (r) { return r.deposito.rate; }, F.pct] + ]; + pvRows.innerHTML = rows.map(function (row) { + var va = row[1](a), vb = b ? row[1](b) : null; + var changed = b && Math.abs(va - vb) > 1e-9; + var cls = changed ? (vb > va ? ' class="up"' : ' class="down"') : ''; + return '' + row[0] + '' + row[2](va) + '' + + (b ? row[2](vb) : 'isian belum valid') + ''; + }).join(''); + var warn = $('pvWarn'); + var list = dp ? tierWarnings(dp) : []; + warn.hidden = !list.length; + warn.querySelector('span').textContent = list.length ? 'Periksa kembali: ' + list.join(' ') + ' Simpan tetap diizinkan bila memang disengaja.' : ''; + } + if (pvRows) { + $('pvNominal').addEventListener('input', preview); + $('pvTenor').addEventListener('change', preview); + preview(); + } +})(); diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..c688098 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,430 @@ +/* + * Calculator page controller. Results are calculated in the browser (instant, + * also offline) from the folded public model; printing and copying fetch the + * official numbers from calc.php first. A logged-in admin calculates with the + * full parameters to see "Rincian perhitungan". + */ +(function () { + 'use strict'; + var meta = window.SERUBI_PUBLIC; + var F = window.SerubiFormat; + var PE = window.SerubiPublicEngine; + if (!meta || !meta.model || !F || !PE) return; + var model = meta.model; + var full = meta.full && window.SerubiEngine ? meta.full : null; // admin only + var PRODUCT_KEYS = ['giro', 'simanja', 'simpeda']; + var STALE_HOURS = 24; // older than this: the page came from the offline copy + var MAX_MODEL_AGE_DAYS = 30; // older than this: refuse to calculate with it + var VERIFY_TIMEOUT_MS = 4000; + + var $ = function (id) { return document.getElementById(id); }; + var form = $('calcForm'); + var nominalEl = $('nominal'); + var display = $('hasil'); + var tape = $('tape'); + var MAX_NOMINAL = 1e15; + var DEFAULTS = { nominal: 55000000, tenor: 6, cust: 'perorangan' }; + var SHOW_DEPOSITO = false; // perbandingan deposito disembunyikan dari hasil simulasi + var CUST_LABEL = { perorangan: 'Perorangan', badan: 'Badan usaha' }; + var lastResult = null; + var feedTimer = null; + var verifiedKey = null; // inputs whose numbers were confirmed by the server + var blocked = false; + + function esc(s) { + return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); + } + // Shrinks an element's font (down to minPx) until its single line fits its box, + // so very large amounts never wrap mid-number or overflow. + function fitText(el, minPx) { + if (!el) return; + el.style.fontSize = ''; + var size = parseFloat(window.getComputedStyle(el).fontSize); + for (var i = 0; i < 40 && el.scrollWidth > el.clientWidth + 1 && size > minPx; i++) { + size = Math.max(minPx, size * 0.94); + el.style.fontSize = size + 'px'; + } + } + function fitAll() { fitText($('dNet'), 18); fitText(nominalEl, 15); } + + function radio(name) { var el = form.querySelector('input[name="' + name + '"]:checked'); return el ? el.value : null; } + function setRadio(name, value) { + var el = form.querySelector('input[name="' + name + '"][value="' + value + '"]'); + if (el) el.checked = true; + } + function line(label, value, cls) { + return '

' + label + '
' + value + '
'; + } + function tenorText(t) { return t + ' bulan'; } + + function readState() { + return { nominal: F.parseIdr(nominalEl.value), tenor: Number(radio('tenor')), cust: radio('cust') || 'perorangan' }; + } + + function validate(state) { + var err = $('nominalErr'); + var msg = ''; + if (state.nominal === null || state.nominal <= 0) msg = 'Masukkan nominal penempatan dana.'; + else if (state.nominal > MAX_NOMINAL) msg = 'Nominal terlalu besar. Maksimal Rp1.000 triliun.'; + err.textContent = msg; + err.hidden = !msg; + nominalEl.setAttribute('aria-invalid', msg ? 'true' : 'false'); + nominalEl.parentNode.classList.toggle('is-invalid', !!msg); + return !msg; + } + + function note(text) { + var el = $('dNote'); + if (!text) { el.hidden = true; el.innerHTML = ''; return; } + el.innerHTML = '' + text + ''; + el.hidden = false; + } + + function ineligibleText(r) { + if (r.reason === 'tenor') { + return 'Jangka waktu ' + r.input.tenor + ' bulan tidak termasuk program cashback. Pilih ' + + model.cashbackTenors.join(', ').replace(/, (\d+)$/, ', atau $1') + ' bulan.'; + } + if (r.reason === 'nominal') { + return 'Cashback berlaku mulai ' + F.rp(r.minNominal) + '. Kurang ' + F.rp(r.minNominal - r.input.nominal) + ' lagi.'; + } + if (r.reason === 'spread') return 'Spread negatif dengan parameter saat ini, sehingga tidak ada cashback.'; + return ''; + } + + function renderEmpty() { + display.classList.add('is-off'); + $('dNet').textContent = 'Rp0'; + ['dGross', 'dMonthly'].forEach(function (id) { $(id).textContent = '—'; }); + $('dEquiv').textContent = '—'; + $('dFlag').textContent = ''; + note(''); + } + + function render(r) { + var ok = r.eligible; + var t = r.input.tenor; + display.classList.toggle('is-off', !ok); + + // display + $('dNet').textContent = F.rp(r.cashback.net); + fitText($('dNet'), 18); + $('dGross').textContent = F.rp(r.cashback.gross); + $('dEquiv').textContent = F.pct(r.cashback.equiv) + ' p.a.'; + $('dMonthly').textContent = F.rp(r.cashback.monthly); + $('dFlag').textContent = !ok ? 'Tanpa cashback' : (typeof r.share === 'number' ? 'Porsi nasabah ' + F.pct(r.share).replace(',00', '') : 'Mendapat cashback'); + note(ok ? '' : ineligibleText(r)); + $('mNet').textContent = F.rp(r.cashback.net); + $('mEquiv').textContent = ok ? F.pct(r.cashback.equiv) + ' p.a.' : 'tanpa cashback'; + + // tape: head + totals + $('tDate').textContent = new Intl.DateTimeFormat('id-ID', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date()); + $('tNominal').textContent = F.rp(r.input.nominal); + $('tTenor').textContent = tenorText(t); + $('tTenor2').textContent = tenorText(t); + $('tCust').textContent = CUST_LABEL[r.input.customerType]; + $('tGross').textContent = F.rp(r.cashback.gross); + $('tTaxRate').textContent = F.pct(r.cashback.taxRate).replace(',00', ''); + $('tTax').textContent = '−' + F.rp(r.cashback.tax); + $('tNet').textContent = F.rp(r.cashback.net); + $('tEquiv').textContent = F.pct(r.cashback.equiv); + + renderComparison(r); + if ($('calcLines')) renderCalcLines(r); // only present for a logged-in admin + renderPenalty(r); + } + + function renderComparison(r) { + var rows = r.comparison.map(function (c) { return { c: c, dep: false }; }); + if (SHOW_DEPOSITO) rows.push({ dep: true, c: { key: 'deposito', name: 'Deposito', rate: r.deposito.rate, bungaNet: r.deposito.net, cashbackNet: 0, total: r.deposito.net, totalRate: r.deposito.rate } }); + var max = Math.max.apply(null, rows.map(function (x) { return x.c.total; })) || 1; + var bestKey = rows.reduce(function (a, b) { return b.c.total > a.c.total ? b : a; }).c.key; + + var html = rows.map(function (x) { + var c = x.c; + var best = c.key === bestKey && c.total > 0; + var cls = 'cmp-row' + (x.dep ? ' is-dep' : '') + (best ? ' is-best' : ''); + var tag = x.dep ? 'Pembanding' : (best ? 'Tertinggi' : ''); + var wBunga = Math.max(0, c.bungaNet) / max * 100; + var wCb = Math.max(0, c.cashbackNet) / max * 100; + var diff = ''; + if (SHOW_DEPOSITO && !x.dep) { + var d = c.total - r.deposito.net; + diff = '

' + (d >= 0 ? '+' : '−') + F.rp(Math.abs(d)).replace('−', '') + + (d >= 0 ? ' lebih besar' : ' lebih kecil') + ' dari deposito

'; + } + return '
  • ' + + '
    ' + esc(c.name) + tag + '' + F.rp(c.total) + '
    ' + + '' + + '
    ' + + '
    Rate
    ' + F.pct(c.rate) + '
    ' + + '
    Bunga neto
    ' + F.rp(c.bungaNet) + '
    ' + + (x.dep ? '
    Cashback
    —
    ' : '
    Cashback
    ' + F.rp(c.cashbackNet) + '
    ') + + '
    Total rate
    ' + F.pct(c.totalRate) + '
    ' + + '
    ' + diff + '
  • '; + }).join(''); + $('cmp').innerHTML = html; + // widths set through the CSSOM: allowed by the Content-Security-Policy (inline style attributes are not) + $('cmp').querySelectorAll('.cmp-bar i[data-w]').forEach(function (i) { i.style.width = i.getAttribute('data-w') + '%'; }); + if (!document.getElementById('cmpLegend')) { + $('cmp').insertAdjacentHTML('beforebegin', '

    Bunga netoCashback neto

    '); + } + } + + function renderCalcLines(r) { + var s = full.settings, f = r.fund, t = r.input.tenor; + var html = '

    Dana kelolaan

    ' + + line('Nominal penempatan', F.rp(r.input.nominal)) + + line('GWM ' + F.pct(s.gwm), '−' + F.rp(f.gwm), 'is-neg') + + line('Kas ' + F.pct(s.kas), '−' + F.rp(f.kas), 'is-neg') + + line('Dana kelolaan', F.rp(f.managed), 'is-strong') + '
    '; + + html += '

    Pendapatan bank selama ' + t + ' bulan

    ' + + line('PUAB' + F.pct(s.porsi_puab).replace(',00', '') + ' dana × ' + F.pct(s.puab) + ' × ' + s.hari_sebulan + '/' + s.hari_setahun + ' × ' + t + '', F.rp(f.puabIncome)) + + line('Kredit' + F.pct(s.porsi_kredit).replace(',00', '') + ' dana × ' + F.pct(s.kredit) + ' × ' + t + '/12', F.rp(f.creditIncome)) + + line('Total pendapatan', F.rp(f.income), 'is-strong') + '
    '; + + PRODUCT_KEYS.forEach(function (key) { + var pr = r.products[key]; + html += '

    Spread ' + esc(pr.name) + ' (' + F.pct(pr.rate) + ')

    ' + + line('Bunga kotor produk', '−' + F.rp(pr.bungaGross), 'is-neg') + + (pr.program ? line('Biaya program', '−' + F.rp(pr.program), 'is-neg') : '') + + line('Overhead cost ' + F.pct(s.ohc) + ' × PUAB', '−' + F.rp(pr.ohc), 'is-neg') + + line('Premi LPS ' + F.pct(s.premi_lps), '−' + F.rp(pr.lps), 'is-neg') + + line('Spread', F.rp(pr.spread), 'is-strong') + + line('Porsi nasabah ' + F.pct(r.share).replace(',00', ''), F.rp(pr.cashbackGross)) + '
    '; + }); + + html += '

    Cashback

    ' + + line('Rata-rata porsi nasabah 3 produk', F.rp(r.cashback.gross)) + + line('Pajak cashback ' + F.pct(r.cashback.taxRate), '−' + F.rp(r.cashback.tax), 'is-neg') + + line('Cashback neto', F.rp(r.cashback.net), 'is-strong') + + line('Cashback per bulan', F.rp(r.cashback.monthly)) + + line('Porsi bank', F.pct(r.shareBank).replace(',00', '')) + '
    '; + + html += '

    Deposito pembanding

    ' + + line('Rate deposito ' + t + ' bulan', F.pct(r.deposito.rate)) + + line('Bunga kotor', F.rp(r.deposito.gross)) + + line('Pajak ' + F.pct(s.pajak_deposito), '−' + F.rp(r.deposito.tax), 'is-neg') + + line('Bunga neto', F.rp(r.deposito.net), 'is-strong') + + line('Bunga neto per bulan', F.rp(r.deposito.monthly)) + '
    '; + $('calcLines').innerHTML = html; + } + + function renderPenalty(r) { + var p = r.penalty; + var pinalti = typeof p.rate === 'number' ? p.rate : model.pinalti; + $('penaltyLines').innerHTML = r.eligible ? + line('Pengembalian cashback neto', F.rp(p.refund)) + + line('Pajak cashback', F.rp(p.tax)) + + line('Pinalti ' + F.pct(pinalti).replace(',00', '') + ' dari cashback neto', F.rp(p.fee)) + + line('Total kewajiban nasabah', F.rp(p.total), 'is-strong') : + line('Tidak ada cashback, tidak ada pinalti', 'Rp0'); + } + + function feed() { + clearTimeout(feedTimer); + feedTimer = setTimeout(function () { + tape.classList.remove('is-feeding'); + void tape.offsetWidth; // restart animation + tape.classList.add('is-feeding'); + }, 220); + } + + function syncUrl(state) { + try { + var u = new URL(location.href); + u.searchParams.set('n', state.nominal || ''); + u.searchParams.set('t', state.tenor); + u.searchParams.set('c', state.cust); + history.replaceState(null, '', u); + } catch (e) { /* ignore */ } + } + + function syncChips(n) { + form.querySelectorAll('.chip').forEach(function (b) { b.setAttribute('aria-pressed', String(Number(b.dataset.amount) === n)); }); + } + + function stateKey(st) { return st.nominal + '|' + st.tenor + '|' + st.cust; } + + function localResult(st) { + var input = { nominal: st.nominal, tenor: st.tenor, customerType: st.cust }; + return full ? window.SerubiEngine.calculate(full, input) : PE.calculate(model, input); + } + + function setVerify(ok, when) { + var el = $('tVerify'); + if (ok === null) { el.hidden = true; el.textContent = ''; return; } + el.hidden = false; + el.textContent = ok + ? 'Angka terverifikasi server Bank Papua, ' + new Intl.DateTimeFormat('id-ID', { dateStyle: 'medium', timeStyle: 'short' }).format(when) + '.' + : 'Simulasi lokal (tanpa koneksi): angka belum terverifikasi server.'; + } + + function update(animate) { + var state = readState(); + fitText(nominalEl, 15); + syncChips(state.nominal); + if (blocked || !validate(state)) { lastResult = null; renderEmpty(); return; } + lastResult = localResult(state); + verifiedKey = null; + setVerify(null); + render(lastResult); + syncUrl(state); + if (animate) feed(); + } + + /* + * Official numbers for printing/copying: asks calc.php (server engine, live + * parameters). Without a connection the local simulation is used and marked + * as not verified. Calls done(verified) in both cases. + */ + function verifyThen(done) { + var state = readState(); + if (blocked || !lastResult || !validate(state)) { toast('Isi nominal terlebih dahulu.'); return; } + if (verifiedKey === stateKey(state)) { done(true); return; } + var url = meta.calcUrl + '?n=' + encodeURIComponent(state.nominal) + '&t=' + encodeURIComponent(state.tenor) + '&c=' + encodeURIComponent(state.cust); + var timer; + var timeout = new Promise(function (_, reject) { timer = setTimeout(function () { reject(new Error('timeout')); }, VERIFY_TIMEOUT_MS); }); + toast('Memverifikasi ke server…'); + Promise.race([ + fetch(url, { credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json' } }).then(function (res) { + return res.json().then(function (body) { if (!res.ok) throw new Error(body.error || 'HTTP ' + res.status); return body; }); + }), + timeout + ]).then(function (r) { + clearTimeout(timer); + if (stateKey(readState()) !== stateKey(state)) return; // inputs changed meanwhile + lastResult = r; + verifiedKey = stateKey(state); + render(r); + setVerify(true, new Date()); + toast(''); + done(true); + }, function () { + clearTimeout(timer); + setVerify(false); + toast(''); + done(false); + }); + } + + // Age of the model: an old one means this page came from the offline copy. + function checkModelAge() { + var made = Date.parse(model.generatedAt); + if (isNaN(made)) return; + var hours = (Date.now() - made) / 3600000; + var note = $('modelNote'); + var at = Date.parse(String(model.paramsAt || '').replace(' ', 'T')); + var per = isNaN(at) ? '' : new Intl.DateTimeFormat('id-ID', { dateStyle: 'medium', timeStyle: 'short' }).format(at); + if (hours > MAX_MODEL_AGE_DAYS * 24) { + blocked = true; + note.textContent = 'Parameter tersimpan di perangkat ini sudah lebih dari ' + MAX_MODEL_AGE_DAYS + ' hari. Sambungkan ke internet lalu muat ulang halaman.'; + note.hidden = false; + } else if (hours > STALE_HOURS || navigator.onLine === false) { + note.textContent = 'Mode offline: memakai parameter yang tersimpan di perangkat' + (per ? ' (berlaku per ' + per + ')' : '') + '. Cetak struk dan salin ringkasan akan diverifikasi ke server saat online.'; + note.hidden = false; + } + } + + function setState(s) { + nominalEl.value = F.num(s.nominal); + setRadio('tenor', s.tenor); + setRadio('cust', s.cust); + } + + function summaryText(r, verified) { + var lines = [ + 'Simulasi Cashback SERUBI – Bank Papua', + 'Nominal ' + F.rp(r.input.nominal) + ' · ' + tenorText(r.input.tenor) + ' · ' + CUST_LABEL[r.input.customerType], + r.eligible ? 'Cashback neto: ' + F.rp(r.cashback.net) + ' (setara ' + F.pct(r.cashback.equiv) + ' per tahun)' : 'Tanpa cashback: ' + ineligibleText(r), + '', + 'Total diterima selama ' + tenorText(r.input.tenor) + ':' + ]; + r.comparison.forEach(function (c) { lines.push('• ' + c.name + ': ' + F.rp(c.total) + ' (' + F.pct(c.totalRate) + ' p.a.)'); }); + if (SHOW_DEPOSITO) lines.push('• Deposito: ' + F.rp(r.deposito.net) + ' (' + F.pct(r.deposito.rate) + ' p.a.)'); + if (!verified) lines.push('', '(Simulasi lokal tanpa koneksi, belum terverifikasi server.)'); + lines.push('', '*Hasil simulasi (estimasi), bukan dokumen resmi atau penawaran yang mengikat; dapat berubah sesuai ketentuan Bank Papua.'); + return lines.join('\n'); + } + + function toast(msg) { + var el = $('toast'); + el.textContent = msg; + clearTimeout(toast.t); + toast.t = setTimeout(function () { el.textContent = ''; }, 2600); + } + + // ---- events ---- + nominalEl.addEventListener('input', function () { F.liveFormatIdr(nominalEl); update(true); }); + var resizeTimer = null; + window.addEventListener('resize', function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(fitAll, 120); }); + if (document.fonts && document.fonts.ready) document.fonts.ready.then(fitAll); + nominalEl.addEventListener('keydown', function (ev) { if (ev.key === 'Enter') { ev.preventDefault(); nominalEl.blur(); display.focus({ preventScroll: true }); } }); + form.addEventListener('change', function (ev) { if (ev.target.name === 'tenor' || ev.target.name === 'cust') update(true); }); + form.addEventListener('submit', function (ev) { ev.preventDefault(); }); + form.querySelectorAll('.chip').forEach(function (b) { + b.addEventListener('click', function () { nominalEl.value = F.num(Number(b.dataset.amount)); update(true); }); + }); + $('btnReset').addEventListener('click', function () { setState(DEFAULTS); update(true); nominalEl.focus(); }); + $('btnPrint').addEventListener('click', function () { + verifyThen(function () { + var d = document.querySelectorAll('.tape-details'); + var was = Array.prototype.map.call(d, function (x) { return x.open; }); + d.forEach(function (x) { x.open = true; }); + window.print(); + d.forEach(function (x, i) { x.open = was[i]; }); + }); + }); + $('btnCopy').addEventListener('click', function () { + verifyThen(function (verified) { + var text = summaryText(lastResult, verified); + var done = function () { toast('Ringkasan disalin. Tempel di WhatsApp atau email.'); }; + // the browser may refuse a late clipboard write; a second tap copies at once (already verified) + var failed = function () { toast('Belum tersalin. Tekan "Salin ringkasan" sekali lagi.'); }; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(done, function () { fallbackCopy(text) ? done() : failed(); }); + } else if (fallbackCopy(text)) done(); else failed(); + }); + }); + function fallbackCopy(text) { + var ta = document.createElement('textarea'); + ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0'; + document.body.appendChild(ta); ta.select(); + var ok = false; + try { ok = document.execCommand('copy'); } catch (e) { ok = false; } + document.body.removeChild(ta); + return ok; + } + + // mini display on phones once the main display is out of view + if ('IntersectionObserver' in window) { + var mini = $('miniDisplay'); + new IntersectionObserver(function (entries) { + var hidden = !entries[0].isIntersecting && entries[0].boundingClientRect.top < 0; + mini.classList.toggle('is-shown', hidden && !!lastResult); + }).observe(display); + } + + // details open by default on wide screens + if ($('detailCalc') && window.matchMedia('(min-width: 900px)').matches) $('detailCalc').open = true; + + // initial state: deep link ?n=&t=&c= + var q = new URLSearchParams(location.search); + var init = { + nominal: F.parseIdr(q.get('n')) || DEFAULTS.nominal, + tenor: model.tenors.indexOf(Number(q.get('t'))) >= 0 ? Number(q.get('t')) : DEFAULTS.tenor, + cust: q.get('c') === 'badan' ? 'badan' : DEFAULTS.cust + }; + $('minNominal').textContent = F.rp(model.minNominal); + checkModelAge(); + setState(init); + update(false); + fitAll(); + + // Offline copy of the page and its model (sw.js): the calculator keeps working without signal. + if ('serviceWorker' in navigator && meta.swUrl && window.isSecureContext) { + window.addEventListener('load', function () { navigator.serviceWorker.register(meta.swUrl).catch(function () { /* optional */ }); }); + } +})(); diff --git a/assets/js/engine-public.js b/assets/js/engine-public.js new file mode 100644 index 0000000..9d8a585 --- /dev/null +++ b/assets/js/engine-public.js @@ -0,0 +1,99 @@ +/* + * SERUBI public engine: the customer-facing result, calculated in the browser + * from the folded model of engine_client_model() (includes/engine.php). The + * page never receives the bank's internal parameters, only one margin per + * tenor. Same numbers as assets/js/engine.js and includes/engine.php, checked + * against the reference workbook by tests/validate_public.js. + * Works in the browser (window.SerubiPublicEngine) and in Node (module.exports). + */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.SerubiPublicEngine = factory(); +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + var PRODUCTS = [ + { key: 'giro', name: 'Giro Umum' }, + { key: 'simanja', name: 'Tabungan SIMANJA' }, + { key: 'simpeda', name: 'Tabungan SIMPEDA' } + ]; + + // Excel ROUNDDOWN(x, 0) on 15 significant digits (see engine.js). + function rounddown(x) { + var v = Number(x.toPrecision(15)); + return v < 0 ? Math.ceil(v) : Math.floor(v); + } + + function tierRate(tiers, n) { + if (!tiers || !tiers.length) return 0; + for (var i = 0; i < tiers.length; i++) { + var t = tiers[i]; + if (t.max === null || t.max === undefined || n <= t.max) return Number(t.rate) || 0; + } + return 0; + } + + function shareFor(m, n, tenor) { + if ((m.cashbackTenors || []).indexOf(tenor) === -1) return { share: null, reason: 'tenor' }; + var tiers = (m.cashbackTiers || []).slice().sort(function (a, b) { return b.min - a.min; }); + for (var i = 0; i < tiers.length; i++) { + if (n >= tiers[i].min) return { share: Number(tiers[i].nasabah), reason: null }; + } + return { share: null, reason: 'nominal' }; + } + + function calculate(m, input) { + var n = Math.max(0, Number(input.nominal) || 0); + var t = Number(input.tenor) || 0; + var customerType = input.customerType === 'badan' ? 'badan' : 'perorangan'; + var year = m.days.year, month = m.days.month; + + var sh = shareFor(m, n, t); + var eligible = sh.share !== null; + var share = eligible ? sh.share : 0; + + // Bunga per produk (COB!J24/N24/R24), identical to the full engine + var bunga = {}, bungaSum = 0; + PRODUCTS.forEach(function (prod) { + var rate = tierRate((m.productRates || {})[prod.key], n); + var b = rounddown(((n * rate * month) / year) * t); + bunga[prod.key] = { rate: rate, gross: b }; + bungaSum += b; + }); + + // Rata-rata spread 3 produk = margin × nominal − rata-rata bunga − biaya program/3 − premi LPS + var lps = Math.min(n, m.lps.cap) * m.lps.rate; + var avgSpread = n * (m.margin[t] || 0) - bungaSum / 3 - m.programAvg - lps; + var gross = eligible ? avgSpread * share : 0; + var negative = gross < 0; + if (negative) gross = 0; + var taxRate = m.tax[customerType]; + var tax = gross * taxRate; + var net = gross - tax; + var equiv = n > 0 && t > 0 ? (net / n) * (year / (month * t)) : 0; + + var comparison = PRODUCTS.map(function (prod) { + var b = bunga[prod.key]; + var bungaNet = b.gross - b.gross * m.tax.bunga; + var total = bungaNet + net; + return { key: prod.key, name: prod.name, rate: b.rate, bungaNet: bungaNet, cashbackNet: net, total: total, totalRate: b.rate + equiv }; + }); + + var penalty = { refund: net, tax: gross * taxRate, fee: net * m.pinalti, rate: m.pinalti }; + penalty.total = penalty.refund + penalty.tax + penalty.fee; + + var mins = (m.cashbackTiers || []).map(function (x) { return x.min; }); + return { + input: { nominal: n, tenor: t, customerType: customerType }, + eligible: eligible && !negative, + reason: negative ? 'spread' : sh.reason, + minNominal: mins.length ? Math.min.apply(null, mins) : 0, + share: share, + cashback: { gross: gross, taxRate: taxRate, tax: tax, net: net, monthly: t ? net / t : 0, equiv: equiv }, + comparison: comparison, + penalty: penalty + }; + } + + return { calculate: calculate, PRODUCTS: PRODUCTS }; +}); diff --git a/assets/js/engine.js b/assets/js/engine.js new file mode 100644 index 0000000..629fdee --- /dev/null +++ b/assets/js/engine.js @@ -0,0 +1,157 @@ +/* + * SERUBI cashback engine. + * Faithful port of sheet "COB" and "CALCULATOR CASHBACK SERUBI" from + * CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx. Cell references are + * noted next to each step so the numbers can be audited against the workbook. + * Works in the browser (window.SerubiEngine) and in Node (module.exports). + */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.SerubiEngine = factory(); +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + var PRODUCTS = [ + { key: 'giro', name: 'Giro Umum' }, + { key: 'simanja', name: 'Tabungan SIMANJA' }, + { key: 'simpeda', name: 'Tabungan SIMPEDA' } + ]; + + // Excel ROUNDDOWN(x, 0) works on 15 significant digits; mirror that so + // values like 203424.99999999997 do not lose a rupiah. + function rounddown(x) { + var v = Number(x.toPrecision(15)); + return v < 0 ? Math.ceil(v) : Math.floor(v); + } + + // Tiers are [{max: number|null, rate}], ascending; first tier with n <= max + // wins, a null max means "di atas" (everything above the previous tier). + function tierRate(tiers, n) { + if (!tiers || !tiers.length) return 0; + for (var i = 0; i < tiers.length; i++) { + var t = tiers[i]; + if (t.max === null || t.max === undefined || n <= t.max) return Number(t.rate) || 0; + } + return 0; + } + + // Cashback sharing: [{min, nasabah}] — highest min that n reaches wins (COB!I34:I35). + function shareFor(params, n, tenor) { + if ((params.cashbackTenors || []).indexOf(tenor) === -1) return { share: null, reason: 'tenor' }; + var tiers = (params.cashbackTiers || []).slice().sort(function (a, b) { return b.min - a.min; }); + for (var i = 0; i < tiers.length; i++) { + if (n >= tiers[i].min) return { share: Number(tiers[i].nasabah), reason: null }; + } + return { share: null, reason: 'nominal' }; + } + + function minCashbackNominal(params) { + var mins = (params.cashbackTiers || []).map(function (t) { return t.min; }); + return mins.length ? Math.min.apply(null, mins) : 0; + } + + function depositoRate(params, n, tenor) { + if (n < params.settings.deposito_min) return 0; // COB!K14: F4<1000000 -> 0 + return tierRate((params.depositoRates || {})[tenor], n); + } + + function calculate(params, input) { + var p = params.settings; + var n = Math.max(0, Number(input.nominal) || 0); + var t = Number(input.tenor) || 0; + var customerType = input.customerType === 'badan' ? 'badan' : 'perorangan'; + var year = p.hari_setahun, month = p.hari_sebulan; + + // Pengelolaan dana (COB!F23:F38) + var gwm = n * p.gwm; // F24 + var kas = n * p.kas; // F25 + var managed = n - (gwm + kas); // F26 + var puabBase = managed * p.porsi_puab; // F28 + var puabIncome = puabBase * p.puab * (month / year) * t; // F31 (A) + var creditBase = managed * p.porsi_kredit; // F34 + var creditIncome = creditBase * p.kredit * (t / 12); // F36 (B) + var income = puabIncome + creditIncome; // F38 + + var sh = shareFor(params, n, t); + var eligible = sh.share !== null; + var share = eligible ? sh.share : 0; + + // Biaya & spread per produk (COB!H22:R37) + var lps = Math.min(n, p.lps_batas) * p.premi_lps; // J27 (charged once, not prorated) + var ohc = puabIncome * p.ohc; // J26 / N25 / R25 + var products = {}; + PRODUCTS.forEach(function (prod) { + var rate = tierRate((params.productRates || {})[prod.key], n); // I24 / M24 / Q24 + var bunga = rounddown(((n * rate * month) / year) * t); // J24 / N24 / R24 + var program = prod.key === 'simanja' ? p.biaya_program : 0; // J25 (SIMANJA only) + var cost = bunga + program + ohc + lps; // J28 / N27 / R27 + var spread = income - cost; // J29 / N28 / R28 + var bungaTax = bunga * p.pajak_tabungan; // F7 / F10 / F13 + products[prod.key] = { + key: prod.key, name: prod.name, rate: rate, + bungaGross: bunga, bungaTax: bungaTax, bungaNet: bunga - bungaTax, + program: program, ohc: ohc, lps: lps, cost: cost, spread: spread, + cashbackGross: spread * share, // J35 / N34 / R34 + costOfFund: managed > 0 ? (cost + spread * share) / managed : 0 // J30 / N29 / R29 + }; + }); + + // Cashback = rata-rata 3 produk (COB!H7:H10) + var gross = eligible ? (products.giro.cashbackGross + products.simanja.cashbackGross + products.simpeda.cashbackGross) / 3 : 0; + var negative = gross < 0; + if (negative) gross = 0; + var taxRate = customerType === 'badan' ? p.pajak_cashback_badan : p.pajak_cashback_perorangan; + var tax = gross * taxRate; // H9 + var net = gross - tax; // H10 + var equiv = n > 0 && t > 0 ? (net / n) * (year / (month * t)) : 0; // CALC!D10 + + // Deposito pembanding (COB!K14:L17) + var dRate = depositoRate(params, n, t); + var dGross = rounddown(((n * dRate * month) / year) * t); + var dTax = dGross * p.pajak_deposito; + var dNet = dGross - dTax; + var deposito = { rate: dRate, gross: dGross, tax: dTax, net: dNet, monthly: t ? dNet / t : 0 }; + + // Perbandingan (CALC!A10:F13) + var comparison = PRODUCTS.map(function (prod) { + var pr = products[prod.key]; + var total = pr.bungaNet + net; + return { + key: prod.key, name: prod.name, rate: pr.rate, bungaNet: pr.bungaNet, + cashbackNet: net, total: total, totalRate: pr.rate + equiv, diff: total - dNet + }; + }); + + // Pinalti pencairan sebelum jatuh tempo (CALC!A17:B22) + var penalty = { + refund: net, + tax: gross * taxRate, + fee: net * p.pinalti, + total: 0 + }; + 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: minCashbackNominal(params), + share: share, shareBank: eligible ? 1 - share : 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, equiv: equiv }, + deposito: deposito, + comparison: comparison, + best: comparison.reduce(function (a, b) { return b.total > a.total ? b : a; }), + penalty: penalty + }; + } + + return { + calculate: calculate, tierRate: tierRate, depositoRate: depositoRate, + rounddown: rounddown, PRODUCTS: PRODUCTS + }; +}); diff --git a/assets/js/format.js b/assets/js/format.js new file mode 100644 index 0000000..8cd7513 --- /dev/null +++ b/assets/js/format.js @@ -0,0 +1,54 @@ +/* Indonesian number formatting shared by the calculator and admin pages. */ +(function (root) { + 'use strict'; + var nf0 = new Intl.NumberFormat('id-ID', { maximumFractionDigits: 0 }); + var nf2 = new Intl.NumberFormat('id-ID', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + var nfp = new Intl.NumberFormat('id-ID', { minimumFractionDigits: 0, maximumFractionDigits: 4 }); + + var F = { + /** 1234567.8 -> "Rp1.234.568" */ + rp: function (x) { + var v = Math.round(Number(x) || 0); + return (v < 0 ? '−Rp' : 'Rp') + nf0.format(Math.abs(v)); + }, + /** 0.027 -> "2,70%" */ + pct: function (x) { return nf2.format((Number(x) || 0) * 100) + '%'; }, + /** 0.0075 -> "0,75" (for inputs) */ + pctInput: function (x) { return nfp.format((Number(x) || 0) * 100); }, + num: function (x) { return nf0.format(Math.round(Number(x) || 0)); }, + /** "55.000.000" -> 55000000 */ + parseIdr: function (s) { + var d = String(s == null ? '' : s).replace(/[^0-9]/g, ''); + return d === '' ? null : Number(d); + }, + /** "0,75" / "0.75" -> 0.0075; "" -> null; invalid -> NaN */ + parsePct: function (s) { + var t = String(s == null ? '' : s).replace(/[\s%]/g, '').replace(',', '.'); + if (t === '') return null; + if (!/^-?\d+(\.\d+)?$/.test(t)) return NaN; + return Math.round(parseFloat(t) * 1e6) / 1e8; + }, + /** 50000000 -> "50 jt", 2e9 -> "2 M" */ + short: function (x) { + var v = Number(x) || 0; + if (v >= 1e12) return nfp.format(v / 1e12) + ' T'; + if (v >= 1e9) return nfp.format(v / 1e9) + ' M'; + if (v >= 1e6) return nfp.format(v / 1e6) + ' jt'; + return nf0.format(v); + }, + /** Keeps the caret position stable while re-formatting a thousands-separated input. */ + liveFormatIdr: function (input) { + var before = input.value; + var caret = input.selectionStart == null ? before.length : input.selectionStart; + var digitsBefore = before.slice(0, caret).replace(/[^0-9]/g, '').length; + var n = F.parseIdr(before); + var after = n === null ? '' : nf0.format(n); + input.value = after; + var pos = 0, seen = 0; + while (pos < after.length && seen < digitsBefore) { if (/[0-9]/.test(after[pos])) seen++; pos++; } + try { input.setSelectionRange(pos, pos); } catch (e) { /* not focusable */ } + return n; + } + }; + root.SerubiFormat = F; +})(window); diff --git a/assets/js/lib/qrcode.js b/assets/js/lib/qrcode.js new file mode 100644 index 0000000..76889b5 --- /dev/null +++ b/assets/js/lib/qrcode.js @@ -0,0 +1,2297 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
    '; + } + + qrHtml += '
    '; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(row * cellSize, col * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); diff --git a/assets/js/otp.js b/assets/js/otp.js new file mode 100644 index 0000000..99a6c39 --- /dev/null +++ b/assets/js/otp.js @@ -0,0 +1,10 @@ +/* 2FA activation: draws the otpauth:// QR code locally (no external service). */ +(function () { + 'use strict'; + var box = document.querySelector('[data-otpauth]'); + if (!box || typeof window.qrcode !== 'function') return; + var qr = window.qrcode(0, 'M'); + qr.addData(box.getAttribute('data-otpauth')); + qr.make(); + box.innerHTML = qr.createSvgTag({ cellSize: 5, margin: 4, scalable: true }); +})(); diff --git a/assets/js/theme.js b/assets/js/theme.js new file mode 100644 index 0000000..40f89de --- /dev/null +++ b/assets/js/theme.js @@ -0,0 +1,17 @@ +/* Light/dark toggle; remembers the choice per browser. */ +(function () { + 'use strict'; + var root = document.documentElement; + function current() { + if (root.dataset.theme) return root.dataset.theme; + return window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) { + btn.addEventListener('click', function () { + var next = current() === 'dark' ? 'light' : 'dark'; + root.dataset.theme = next; + try { localStorage.setItem('serubi-theme', next); } catch (e) { /* storage unavailable */ } + btn.setAttribute('aria-label', next === 'dark' ? 'Ganti ke tema terang' : 'Ganti ke tema gelap'); + }); + }); +})(); diff --git a/calc.php b/calc.php new file mode 100644 index 0000000..e9160e1 --- /dev/null +++ b/calc.php @@ -0,0 +1,43 @@ +&t=&c=perorangan|badan +// Visitors receive the customer-facing result only; a logged-in admin also gets +// the internal breakdown shown in "Rincian perhitungan". +require __DIR__ . '/includes/auth.php'; +require __DIR__ . '/includes/engine.php'; + +try { + $admin = viewing_admin(); +} catch (Throwable $ex) { + $admin = null; +} +send_security_headers($admin !== null); +header('Content-Type: application/json; charset=utf-8'); +header('X-Robots-Tag: noindex'); + +function calc_reply(int $status, array $body): void +{ + http_response_code($status); + echo json_encode($body, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION); + exit; +} + +$nominal = parse_idr($_GET['n'] ?? ''); +$tenor = (int)input_str($_GET['t'] ?? ''); +$cust = input_str($_GET['c'] ?? '') === 'badan' ? 'badan' : 'perorangan'; + +try { + $params = load_params(); +} catch (Throwable $ex) { + error_log('[serubi] calc: ' . $ex->getMessage()); + calc_reply(503, ['error' => 'Parameter tidak dapat dimuat.']); +} +if ($nominal === null || $nominal <= 0 || $nominal > 1e15) { + calc_reply(400, ['error' => 'Nominal tidak valid.']); +} +if (!in_array($tenor, $params['tenors'], true)) { + calc_reply(400, ['error' => 'Jangka waktu tidak valid.']); +} + +$result = engine_calculate($params, $nominal, $tenor, $cust); +calc_reply(200, $admin ? $result : engine_public_result($result, $params)); diff --git a/config.php b/config.php new file mode 100644 index 0000000..30a6869 --- /dev/null +++ b/config.php @@ -0,0 +1,51 @@ +/.serubi/secrets.php (outside the document root, preferred) +// 3. /.serubi/secrets.php (hosting without access above the +// document root; the folder is denied over HTTP by .htaccess and .serubi/.htaccess) +$secretsFile = getenv('SERUBI_SECRETS_FILE') ?: ''; +if ($secretsFile === '') { + foreach ([dirname(__DIR__) . '/.serubi/secrets.php', __DIR__ . '/.serubi/secrets.php'] as $candidate) { + if (is_file($candidate)) { + $secretsFile = $candidate; + break; + } + } +} +$secrets = $secretsFile !== '' && is_file($secretsFile) ? (array)require $secretsFile : []; +$env = function (string $name, $default) { + $v = getenv($name); + return $v !== false ? $v : $default; +}; + +return [ + 'db' => [ + 'host' => (string)$env('SERUBI_DB_HOST', $secrets['db_host'] ?? 'localhost'), + 'port' => (int)$env('SERUBI_DB_PORT', $secrets['db_port'] ?? 3306), + 'name' => (string)$env('SERUBI_DB_NAME', $secrets['db_name'] ?? 'k5551916_calcserubibppapua'), + 'user' => (string)$env('SERUBI_DB_USER', $secrets['db_user'] ?? ''), + 'password' => (string)$env('SERUBI_DB_PASS', $secrets['db_pass'] ?? ''), + ], + 'security' => [ + // HTTP is redirected to HTTPS unless SERUBI_FORCE_HTTPS=0 (local development). + 'force_https' => filter_var($env('SERUBI_FORCE_HTTPS', $secrets['force_https'] ?? '1'), FILTER_VALIDATE_BOOLEAN), + // IP reverse proxy / load balancer yang dipercaya untuk X-Forwarded-For/-Proto. + 'trusted_proxies' => array_filter(explode(',', (string)$env('SERUBI_TRUSTED_PROXIES', $secrets['trusted_proxies'] ?? ''))), + // Kosong = form "buat admin pertama" dimatikan (pakai: php database/migrate.php --create-admin). + 'setup_key' => (string)$env('SERUBI_SETUP_KEY', $secrets['setup_key'] ?? ''), + // 32-byte key (base64) that encrypts the admins' OTP secrets. Required for 2FA. + 'app_key' => (string)$env('SERUBI_APP_KEY', $secrets['app_key'] ?? ''), + ], + 'app' => [ + 'name' => 'Kalkulator Cashback SERUBI', + // Naikkan saat rilis; ditambahkan ke URL JS/CSS (bersama waktu ubah file) + // sehingga browser klien langsung memuat versi terbaru. + 'version' => '2.3.0', + 'timezone' => 'Asia/Jayapura', + // Parameter cache (outside the document root). Empty = system temp directory. + 'cache_dir' => (string)$env('SERUBI_CACHE_DIR', $secrets['cache_dir'] ?? ''), + ], +]; diff --git a/database/defaults.php b/database/defaults.php new file mode 100644 index 0000000..a6ad0b3 --- /dev/null +++ b/database/defaults.php @@ -0,0 +1,53 @@ + [ + // key => [value, label, unit, group, description] + 'gwm' => [0.09, 'GWM', 'pct', 'dana', 'Giro Wajib Minimum yang tidak dapat dikelola.'], + 'kas' => [0.02, 'Kas', 'pct', 'dana', 'Porsi kas; semakin kecil semakin baik, maksimal dijaga 4%.'], + 'porsi_puab' => [0.10, 'Porsi penempatan PUAB', 'pct', 'dana', 'Bagian dana kelolaan yang ditempatkan di PUAB.'], + 'porsi_kredit' => [0.90, 'Porsi penyaluran kredit', 'pct', 'dana', 'Bagian dana kelolaan yang disalurkan sebagai kredit konsumtif.'], + 'puab' => [0.051, 'Rate PUAB', 'pct', 'bunga', 'Rate Pasar Uang Antar Bank, JW 30 hari.'], + 'kredit' => [0.09, 'Suku bunga kredit', 'pct', 'bunga', 'Suku bunga kredit konsumtif.'], + 'ohc' => [0.0442, 'Overhead cost (OHC)', 'pct', 'biaya', 'Dikenakan atas pendapatan PUAB, sesuai neraca 31 Maret 2026.'], + 'premi_lps' => [0.002, 'Premi LPS', 'pct', 'biaya', 'Biaya bank kepada LPS, dihitung sekali per penempatan.'], + 'lps_batas' => [2000000000, 'Batas nominal premi LPS', 'idr', 'biaya', 'Premi LPS dihitung maksimal dari nominal ini.'], + 'biaya_program' => [0, 'Biaya program SIMANJA', 'idr', 'biaya', 'Biaya program tambahan, hanya untuk SIMANJA.'], + 'pajak_tabungan' => [0.20, 'Pajak bunga tabungan/giro', 'pct', 'pajak', 'Pajak atas bunga produk tabungan dan giro.'], + 'pajak_deposito' => [0.20, 'Pajak bunga deposito', 'pct', 'pajak', 'Pajak atas bunga deposito pembanding.'], + 'pajak_cashback_perorangan' => [0.20, 'Pajak cashback perorangan', 'pct', 'pajak', 'Pajak cashback untuk nasabah perorangan.'], + 'pajak_cashback_badan' => [0.20, 'Pajak cashback badan usaha', 'pct', 'pajak', 'Pajak cashback untuk nasabah badan usaha.'], + 'pinalti' => [0.25, 'Pinalti pencairan dini', 'pct', 'konvensi', 'Pinalti dari cashback neto bila dana dicairkan sebelum jatuh tempo.'], + 'deposito_min' => [1000000, 'Minimal nominal deposito', 'idr', 'konvensi', 'Di bawah nominal ini deposito tidak mendapat bunga.'], + 'hari_setahun' => [365, 'Hari bunga setahun', 'days', 'konvensi', 'Parameter hari bunga.'], + 'hari_sebulan' => [30, 'Hari bunga sebulan', 'days', 'konvensi', 'Jumlah hari per bulan dalam rumus bunga.'], + ], + 'tenors' => [ + // months => cashback eligible + 1 => false, 3 => true, 6 => true, 12 => true, 24 => true, + ], + 'product_rates' => [ + // ALCO Januari 2026, ok ALCO September 2026 (COB!T47:V73); null max = di atas + 'simanja' => [[100000, 0], [100000000, 0.0075], [500000000, 0.01], [1000000000, 0.02], [null, 0.025]], + 'simpeda' => [[100000, 0], [100000000, 0.005], [500000000, 0.0075], [1000000000, 0.015], [null, 0.02]], + 'giro' => [[50000000, 0], [500000000, 0.005], [1000000000, 0.005], [50000000000, 0.005], [null, 0.0075]], + ], + 'deposito_bands' => [100000000, 250000000, 500000000, 1000000000, null], + 'deposito_rates' => [ + // ALCO Maret 2025 (COB!T2:V45), one rate per band + 1 => [0.03, 0.0305, 0.031, 0.0325, 0.034], + 3 => [0.031, 0.0315, 0.032, 0.0335, 0.035], + 6 => [0.032, 0.0325, 0.033, 0.0345, 0.035], + 12 => [0.033, 0.0335, 0.034, 0.035, 0.035], + 24 => [0.034, 0.0345, 0.035, 0.035, 0.035], + ], + 'cashback_tiers' => [ + // min nominal => porsi nasabah (COB!I35); porsi bank = sisanya (COB!I34) + [50000000, 0.50], + [250000001, 0.45], + [2000000000, 0.30], + ], +]; diff --git a/database/migrate.php b/database/migrate.php new file mode 100644 index 0000000..cc06d02 --- /dev/null +++ b/database/migrate.php @@ -0,0 +1,106 @@ +errorInfo[1] ?? $e->getCode()) !== 1049) { // 1049 = unknown database + throw $e; + } + // First install with a privileged account: create the database. + $name = str_replace('`', '', app_config()['db']['name']); + db_connect(app_config()['db'], false) + ->exec("CREATE DATABASE IF NOT EXISTS `$name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $pdo = db(); +} +db_migrate($pdo); + +if (in_array('--reseed', $argv, true)) { + db_seed($pdo, true); + fwrite(STDERR, "Parameters reset to workbook defaults.\n"); +} + +if (in_array('--dump-params', $argv, true)) { + echo json_encode(load_params(), JSON_PRETTY_PRINT), "\n"; + exit; +} + +if (in_array('--dump-public-model', $argv, true)) { + require __DIR__ . '/../includes/engine.php'; + echo json_encode(engine_client_model(load_params_db()), JSON_PRETTY_PRINT | JSON_PRESERVE_ZERO_FRACTION), "\n"; + exit; +} + +if (in_array('--create-admin', $argv, true)) { + $username = strtolower(trim(ask('Username'))); + $name = trim(ask('Nama lengkap')); + $password = ask('Password (min 10, huruf+angka+simbol)', true); + $confirm = ask('Ulangi password', true); + if ($err = validate_new_admin($username, $name, $password, $confirm)) { + fwrite(STDERR, "Gagal: $err\n"); + exit(1); + } + $st = $pdo->prepare('SELECT COUNT(*) FROM admins WHERE username = ?'); + $st->execute([$username]); + if ((int)$st->fetchColumn() > 0) { + fwrite(STDERR, "Gagal: username sudah dipakai.\n"); + exit(1); + } + // The first super admin is created here; later admins are added from the admin page. + $role = (int)$pdo->query("SELECT COUNT(*) FROM admins WHERE role = 'super_admin' AND is_active = 1")->fetchColumn() === 0 ? 'super_admin' : 'admin'; + $pdo->prepare('INSERT INTO admins (username, full_name, role, password_hash) VALUES (?,?,?,?)') + ->execute([$username, $name, $role, password_hash($password, PASSWORD_DEFAULT)]); + $pdo->prepare("INSERT INTO audit_log (action, summary, ip) VALUES ('user.create', ?, 'cli')") + ->execute(["Membuat admin $username dari konsol server"]); + fwrite(STDOUT, "Admin $username dibuat.\n"); + exit; +} + +if (in_array('--create-db-user', $argv, true)) { + // Run once with the privileged account currently in config.php. + $c = app_config()['db']; + $user = 'serubi_app'; + $pass = rtrim(strtr(base64_encode(random_bytes(24)), '+/', 'Aa'), '='); + // Local DB server: allow only local connections. Remote DB: the web server connects from elsewhere. + $hosts = in_array($c['host'], ['127.0.0.1', 'localhost'], true) ? ['localhost', '127.0.0.1'] : ['%']; + $db = str_replace('`', '', $c['name']); + foreach ($hosts as $h) { + $account = "'$user'@'$h'"; + $pdo->exec("CREATE USER IF NOT EXISTS $account IDENTIFIED BY " . $pdo->quote($pass)); + $pdo->exec("ALTER USER $account IDENTIFIED BY " . $pdo->quote($pass)); + $pdo->exec("GRANT SELECT, INSERT, UPDATE, DELETE ON `$db`.* TO $account"); + } + $pdo->exec('FLUSH PRIVILEGES'); + fwrite(STDOUT, "User MySQL '$user' dibuat dengan hak SELECT/INSERT/UPDATE/DELETE pada `$db` saja.\n"); + fwrite(STDOUT, "Ganti di config.php (atau environment variable):\n 'user' => '$user',\n 'password' => '$pass',\n"); + exit; +} + +$c = app_config()['db']; +fwrite(STDERR, sprintf("Database `%s` ready on %s:%d.\n", $c['name'], $c['host'], $c['port'])); diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..c94672b --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,85 @@ +-- SERUBI cashback calculator schema (MySQL 8). +-- Applied automatically by includes/db.php on first connection, or run: +-- php database/migrate.php + +CREATE TABLE IF NOT EXISTS settings ( + skey VARCHAR(64) NOT NULL PRIMARY KEY, + value DECIMAL(24,8) NOT NULL, + label VARCHAR(120) NOT NULL, + unit VARCHAR(10) NOT NULL DEFAULT 'pct', + grp VARCHAR(20) NOT NULL, + description VARCHAR(255) NULL, + sort INT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tenors ( + months TINYINT UNSIGNED NOT NULL PRIMARY KEY, + cashback TINYINT(1) NOT NULL DEFAULT 1 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS product_rates ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + product VARCHAR(20) NOT NULL, + max_nominal DECIMAL(20,0) NULL COMMENT 'NULL = di atas tier sebelumnya', + rate DECIMAL(10,6) NOT NULL, + sort INT NOT NULL DEFAULT 0, + KEY idx_product (product, sort) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS deposito_bands ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + max_nominal DECIMAL(20,0) NULL COMMENT 'NULL = di atas band sebelumnya', + sort INT NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS deposito_rates ( + band_id INT UNSIGNED NOT NULL, + tenor TINYINT UNSIGNED NOT NULL, + rate DECIMAL(10,6) NOT NULL, + PRIMARY KEY (band_id, tenor), + CONSTRAINT fk_deposito_band FOREIGN KEY (band_id) REFERENCES deposito_bands(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS cashback_tiers ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + min_nominal DECIMAL(20,0) NOT NULL, + share_nasabah DECIMAL(10,6) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admins ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + full_name VARCHAR(120) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'admin' COMMENT 'super_admin | admin', + password_hash VARCHAR(255) NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + must_change_password TINYINT(1) NOT NULL DEFAULT 0, + otp_secret VARCHAR(255) NULL COMMENT 'TOTP secret, encrypted with security.app_key', + otp_enabled TINYINT(1) NOT NULL DEFAULT 0, + otp_last_step BIGINT NULL COMMENT 'last accepted TOTP time step (replay guard)', + recovery_codes TEXT NULL COMMENT 'JSON list of HMAC-hashed one-time recovery codes', + last_login_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + admin_id INT UNSIGNED NULL, + username VARCHAR(50) NULL, + action VARCHAR(40) NOT NULL, + summary VARCHAR(255) NOT NULL, + changes JSON NULL, + ip VARCHAR(45) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS login_attempts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + ip VARCHAR(45) NOT NULL, + username VARCHAR(50) NULL, + success TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_ip_time (ip, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/secrets.example.php b/database/secrets.example.php new file mode 100644 index 0000000..a1f06af --- /dev/null +++ b/database/secrets.example.php @@ -0,0 +1,20 @@ +/.serubi/secrets.php outside the document root (preferred) +// /.serubi/secrets.php when the hosting gives no access above the +// document root; .serubi/.htaccess denies HTTP +// or point SERUBI_SECRETS_FILE to another location. +// +// Generate app_key once: php -r "echo base64_encode(random_bytes(32)), PHP_EOL;" +// Keep app_key unchanged afterwards: it decrypts the admins' OTP secrets. +return [ + 'db_host' => 'localhost', + 'db_port' => 3306, + 'db_name' => 'NAMA_DATABASE', + 'db_user' => 'USER_DATABASE', // user khusus aplikasi, hanya SELECT/INSERT/UPDATE/DELETE + 'db_pass' => 'PASSWORD_DATABASE', + 'app_key' => 'HASIL_base64_encode(random_bytes(32))', + // 'trusted_proxies' => '', // IP reverse proxy, dipisah koma (kosongkan bila tidak ada) + // 'force_https' => '1', + // 'cache_dir' => '/home//.serubi/cache', +]; diff --git a/database/upgrade-2.3.0.sql b/database/upgrade-2.3.0.sql new file mode 100644 index 0000000..a589406 --- /dev/null +++ b/database/upgrade-2.3.0.sql @@ -0,0 +1,26 @@ +-- Upgrade an existing calcserubi database to version 2.3.0 +-- (super-admin role, mandatory password change after reset, TOTP 2FA). +-- Run ONCE, manually (phpMyAdmin > tab SQL, or the mysql client), with an +-- account that may ALTER the database. Then the application user only needs +-- SELECT, INSERT, UPDATE, DELETE. + +ALTER TABLE admins + ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'admin' COMMENT 'super_admin | admin' AFTER full_name, + ADD COLUMN must_change_password TINYINT(1) NOT NULL DEFAULT 0 AFTER is_active, + ADD COLUMN otp_secret VARCHAR(255) NULL COMMENT 'TOTP secret, encrypted with security.app_key' AFTER must_change_password, + ADD COLUMN otp_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER otp_secret, + ADD COLUMN otp_last_step BIGINT NULL COMMENT 'last accepted TOTP time step (replay guard)' AFTER otp_enabled, + ADD COLUMN recovery_codes TEXT NULL COMMENT 'JSON list of HMAC-hashed one-time recovery codes' AFTER otp_last_step; + +-- The oldest active admin becomes the first super admin. Check the result and +-- adjust if another person should hold the role: +-- SELECT id, username, role FROM admins; +UPDATE admins SET role = 'super_admin' +WHERE id = (SELECT id FROM (SELECT MIN(id) AS id FROM admins WHERE is_active = 1) AS first_admin); + +-- Workbook cell references removed from older seeded descriptions (previously done on +-- every request). Written without REGEXP_REPLACE so it also runs on MySQL 5.7. +UPDATE settings SET description = TRIM(SUBSTRING_INDEX(description, ' (COB!', 1)) +WHERE description LIKE '% (COB!%'; +UPDATE settings SET description = TRIM(SUBSTRING_INDEX(description, ' (CALC!', 1)) +WHERE description LIKE '% (CALC!%'; diff --git a/dbtest.php b/dbtest.php new file mode 100644 index 0000000..5d9aa60 --- /dev/null +++ b/dbtest.php @@ -0,0 +1,25 @@ +'; +echo 'PDO: ' . (class_exists('PDO') ? 'ya' : 'TIDAK ADA') . '
    '; +echo 'pdo_mysql: ' . (extension_loaded('pdo_mysql') ? 'ya' : 'TIDAK ADA') . '
    '; +echo 'config.php: ' . (is_file(__DIR__ . '/config.php') ? 'ada' : 'TIDAK ADA di ' . __DIR__) . '

    '; + +try { + $cfg = require __DIR__ . '/config.php'; + $c = $cfg['db']; + foreach (['localhost', '127.0.0.1', $c['host']] as $h) { + try { + new PDO("mysql:host=$h;port={$c['port']};dbname={$c['name']};charset=utf8mb4", + $c['user'], $c['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + echo "$h : OK
    "; + } catch (Throwable $e) { + echo "$h : " . htmlspecialchars($e->getMessage()) . '
    '; + } + } +} catch (Throwable $e) { + echo 'FATAL: ' . htmlspecialchars(get_class($e) . ': ' . $e->getMessage() + . ' di ' . $e->getFile() . ':' . $e->getLine()); +} diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..2b4e329 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,88 @@ +# Keamanan — Kalkulator Cashback SERUBI + +Ditulis untuk: admin server yang men-deploy aplikasi ke internet. + +## Sudah ditangani di aplikasi (berlaku otomatis setelah file diunggah) + +| Area | Perlindungan | +|---|---| +| Header keamanan | CSP ketat dengan nonce (tanpa `unsafe-inline`), `X-Frame-Options: DENY`, `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy`, `Permissions-Policy`, COOP/CORP. HSTS aktif otomatis saat diakses lewat HTTPS. | +| XSS | Semua output di-escape (`e()`); input bertipe array dibuang; nama admin menolak `< >` dan karakter kontrol. | +| SQL injection | Semua query memakai PDO prepared statement, dengan emulasi prepare dimatikan. | +| CSRF | Token per sesi di setiap form POST, dibandingkan dengan `hash_equals`; cookie `SameSite=Strict`. Token diganti saat login. | +| Login | CAPTCHA self-hosted (gambar dari server, sekali pakai, 5 menit) diperiksa sebelum password. Kunci 15 menit setelah 5 kali gagal per IP. Tidak ada kunci per username (mencegah akun admin dikunci orang lain); kegagalan beruntun dicatat di Riwayat dan diberitahukan ke pemilik akun saat login berikutnya. Pesan error sama untuk username atau password salah; hash palsu supaya waktu respons tidak membocorkan username. | +| Dua faktor (2FA) | Wajib untuk semua admin: TOTP (RFC 6238) dari Google/Microsoft Authenticator. Diaktifkan saat login pertama (QR code dibuat lokal, tanpa layanan luar). Rahasia OTP dienkripsi AES-256-GCM dengan `app_key`; kode tidak bisa dipakai ulang; 5 kode salah membatalkan login; 10 kode pemulihan sekali pakai. Sesi admin baru dibuat setelah OTP benar. | +| Hak akses admin | Peran **super admin** dan **admin**. Hanya super admin yang dapat menambah, menonaktifkan, me-reset password/OTP, dan mengubah peran admin lain, dan setiap aksi itu meminta password super admin (re-autentikasi). Password hasil reset wajib diganti saat login berikutnya. Super admin aktif terakhir tidak dapat dinonaktifkan atau diturunkan. | +| Parameter bank | Halaman publik menghitung di browser (instan, tetap jalan offline) dengan **model koefisien**: biaya internal dilebur menjadi satu angka margin per tenor (`engine_client_model()`, `assets/js/engine-public.js`). GWM, kas, porsi PUAB/kredit, rate PUAB, bunga kredit, OHC, dan rate deposito tidak dikirim. **Cetak struk** dan **Salin ringkasan** mengambil angka resmi dari server (`calc.php`); tanpa koneksi ditandai "belum terverifikasi server". Rincian perhitungan hanya untuk admin yang login. | +| Kredensial | Tidak ada password di `config.php`. Kredensial database dan `app_key` dibaca dari environment variable atau berkas `.serubi/secrets.php`: di luar document root bila memungkinkan, atau di folder `.serubi/` dalam folder aplikasi yang ditolak dari akses HTTP (403). Contoh: `database/secrets.example.php`. | +| Beban database | Tidak ada migrasi otomatis per request; parameter di-cache (5 menit, dihapus otomatis setiap admin menyimpan). +| Password | 10–72 karakter, berisi huruf, angka, dan simbol, dan tidak boleh memuat username. Disimpan dengan bcrypt (`password_hash`). | +| Sesi | ID baru setiap kali login dan setiap 15 menit; habis setelah 30 menit tidak aktif atau 8 jam sejak login; `HttpOnly`, `Secure` (di HTTPS), strict mode. **Satu sesi aktif per akun**: login di browser/perangkat lain otomatis mengakhiri sesi sebelumnya (dengan pesan). Mengganti password juga mengakhiri sesi lain akun tersebut. | +| Admin pertama | Form "buat admin pertama" mati secara default. Admin pertama dibuat lewat konsol (`--create-admin`), atau form diaktifkan sementara dengan `setup_key` minimal 16 karakter. | +| Bocoran informasi | Error PHP tidak pernah ditampilkan (hanya dicatat di log); halaman error polos; `X-Powered-By` dihapus. | +| Method HTTP | Hanya GET/HEAD/POST yang diterima; PUT/DELETE/OPTIONS/PATCH/TRACK ditolak. | +| File internal | `config.php`, `includes/`, `database/`, `tests/`, `docs/`, file `.md/.sql/.json/.bak/~`, dan semua dotfile (`.git`, `.env`, …) diblokir. Listing folder dimatikan. | +| Input berlebih | Maksimal 30 baris per tabel; nominal maksimal Rp1.000 triliun. | +| Cache | Halaman admin dikirim dengan `no-store`. | + +## WAJIB dikerjakan di server + +Bagian ini **tidak bisa** diatur dari aplikasi. Biasanya inilah sumber temuan pentest yang tersisa. + +1. **HTTPS.** Pasang sertifikat TLS, lalu aktifkan pengalihan HTTP→HTTPS lewat environment `SERUBI_FORCE_HTTPS=1` atau `'force_https' => true` di `config.php`. Izinkan hanya TLS 1.2/1.3: + ```apache + SSLProtocol -all +TLSv1.2 +TLSv1.3 + SSLHonorCipherOrder off + ``` + Kalau aplikasi berada di belakang reverse proxy atau load balancer, isi IP proxy tersebut di `SERUBI_TRUSTED_PROXIES`. +2. **Sembunyikan versi dan matikan TRACE** di `httpd.conf`. Pengaturan ini tidak bisa lewat `.htaccess`: + ```apache + ServerTokens Prod + ServerSignature Off + TraceEnable Off + ``` +3. **php.ini produksi:** + ```ini + expose_php = Off + display_errors = Off + log_errors = On + session.cookie_secure = 1 + session.use_strict_mode = 1 + ``` +4. **Perbarui software.** PHP 8.1 sudah *end-of-life* sejak 31 Desember 2025, dan OpenSSL 1.1.1 sejak September 2023. Pentest akan melaporkan versi yang usang. Gunakan PHP 8.3 atau 8.4, Apache 2.4 versi terbaru, dan OpenSSL 3.x. Aplikasi ini kompatibel dengan PHP 7.4 sampai 8.4. +5. **Jangan pakai user `root` untuk MySQL.** Jalankan sekali dengan akun root: + ``` + php database/migrate.php --create-db-user + ``` + Perintah ini membuat user `serubi_app` yang hanya punya hak SELECT/INSERT/UPDATE/DELETE pada database `calcserubi`. Salin user dan password yang dicetak ke `config.php` atau ke environment `SERUBI_DB_USER`/`SERUBI_DB_PASS`. Setelah itu ganti juga password `root` MySQL, karena password yang sekarang pernah tertulis di config. +6. **MySQL jangan terbuka ke internet.** Set `bind-address = 127.0.0.1` dan tutup port 3306/3309 di firewall. +7. **Hak akses file.** File aplikasi dibuat read-only untuk user web server. `config.php` hanya bisa dibaca oleh user web server (misalnya `chmod 640`). +8. **Folder yang tidak perlu ada di server produksi** (opsional, karena sudah diblokir): `tests/`, `docs/`, `.impeccable/`, `PRODUCT.md`, `DESIGN.md`. +9. **AllowOverride.** Pastikan `AllowOverride All` (atau minimal `AuthConfig FileInfo Indexes Limit Options`) untuk folder aplikasi. Tanpa itu `.htaccess` diabaikan dan file internal bisa terbuka. Cek dengan membuka `https:///config.php`; hasilnya harus 403/404. + +## Cek cepat setelah deploy + +``` +curl -sI https:/// | grep -iE "server|x-powered|content-security|strict-transport" +curl -s -o /dev/null -w "%{http_code}\n" https:///config.php # 403 +curl -s -o /dev/null -w "%{http_code}\n" -X TRACE https:/// # 403/405 +curl -s -o /dev/null -w "%{http_code}\n" https:///.git/config # 404 +``` + +Hasil yang diharapkan: +- `Server: Apache`, tanpa nomor versi. +- Tidak ada `X-Powered-By`. +- Ada `Content-Security-Policy` dan `Strict-Transport-Security`. + +## Upgrade ke versi 2.3.0 (sekali) + +1. Jalankan `database/upgrade-2.3.0.sql` di phpMyAdmin (tab SQL). Admin aktif paling lama menjadi super admin; periksa dengan `SELECT id, username, role FROM admins;`. +2. Buat `secrets.php` dari `database/secrets.example.php` (chmod 600), isi kredensial database dan `app_key`. Letakkan di `/home//.serubi/` bila ada akses, atau di `/.serubi/` (folder ini sudah berisi `.htaccess` yang menolak akses HTTP). Cek: `https:///.serubi/secrets.php` harus 403. +3. Batasi user database aplikasi di cPanel menjadi SELECT, INSERT, UPDATE, DELETE saja. +4. Unggah file aplikasi. Setiap admin akan diminta mengaktifkan authenticator saat login berikutnya. +5. Uji: `php tests/validate_engine.php` dan `node tests/validate.js` harus 4.805/4.805; `node tests/validate_public.js` harus 3.100/3.100. +6. Mode offline: `sw.js` (service worker) menyimpan halaman kalkulator. Model berumur lebih dari 30 hari ditolak; perangkat harus online untuk memperbarui. + +## Tindakan untuk akun admin yang sudah ada + +Aturan password baru berlaku saat password dibuat atau diganti. Akun admin yang dibuat sebelum update ini sebaiknya **mengganti password** lewat menu *Pengguna admin → Password Anda*. Setelah update, semua admin yang sedang login akan diminta login ulang satu kali. diff --git a/docs/VALIDASI.md b/docs/VALIDASI.md new file mode 100644 index 0000000..3f7710f --- /dev/null +++ b/docs/VALIDASI.md @@ -0,0 +1,52 @@ +# Validasi Perhitungan vs Excel + +Sumber acuan: **CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx** (sheet `COB` dan `CALCULATOR CASHBACK SERUBI`). + +## Metode + +1. `tests/export-excel-fixtures.ps1` membuka salinan workbook di **Microsoft Excel**, mengisi nominal (A6) dan jangka waktu (B6), lalu membiarkan Excel menghitung ulang. Ada 31 nominal × 5 tenor = **155 skenario**, termasuk batas-batas tier (49.999.999 / 50.000.000, 250.000.000 / 250.000.001, 1.999.999.999 / 2.000.000.000, dan seterusnya). Hasilnya disimpan di `tests/excel-fixtures.json`. +2. `tests/validate.js` menjalankan engine aplikasi (`assets/js/engine.js`) dengan **parameter yang dibaca dari MySQL**, lalu membandingkan 31 angka per skenario: cashback bruto/neto, bunga neto tiap produk, deposito, total diterima, rate, total rate, pinalti, porsi, pendapatan, dan spread. + - Nominal rupiah harus sama sampai rupiah (selisih < Rp0,01). + - Rate harus sama sampai 1e-9. + +Cara menjalankan: + +``` +powershell -ExecutionPolicy Bypass -File tests\export-excel-fixtures.ps1 # perlu Excel +node tests\validate.js --legacy +``` + +## Hasil + +| Yang diuji | Cocok dengan Excel | +|---|---| +| Aplikasi baru (parameter dari MySQL) | **4.805 / 4.805 cek (100%)** | +| Aplikasi HTML lama | 2.870 / 3.410 cek | + +Contoh: Rp55.000.000, 6 bulan, perorangan. Angka Excel sama persis dengan aplikasi. + +| | Excel | Aplikasi | +|---|---|---| +| Cashback bruto | 915.963,68 | 915.963,68 | +| Cashback neto | 732.770,94 | 732.770,94 | +| Ekuivalen per tahun | 2,7016% | 2,7016% | +| Total SIMANJA | 895.510,14 | 895.510,14 | +| Deposito neto | 694.356 | 694.356 | +| Total kewajiban pinalti | 1.099.156,42 | 1.099.156,42 | + +## Temuan pada aplikasi HTML lama (sudah diperbaiki) + +1. **Baris DEPOSITO, kolom "Bunga Neto" menampilkan bunga kotor.** Salah di 140 dari 155 skenario. Contoh Rp55 jt / 6 bulan: tertulis Rp867.945, padahal bunga neto (Excel H6) Rp694.356. +2. **Bagian pinalti cashback (Excel A15:B22) tidak ada.** Sekarang tampil di bagian "Bila dicairkan sebelum jatuh tempo". +3. **Pilihan "Jenis Nasabah" tidak berpengaruh.** Sekarang memakai pajak cashback perorangan atau badan usaha (keduanya 20%, sesuai U85/U86). +4. Semua parameter tertanam di kode. Sekarang semuanya ada di database dan bisa diubah lewat halaman admin. + +Perhitungan cashback bruto/neto pada aplikasi lama sendiri sudah benar. + +## Catatan pada file Excel (tidak diubah, mohon dikonfirmasi) + +- **Premi LPS dihitung penuh 0,20% tanpa melihat jangka waktu** (`COB!J27 = MIN(nominal; 2 M) × 0,2%`), padahal premi ini bersifat tahunan. Aplikasi mengikuti Excel apa adanya. +- **Pajak cashback di `COB!H8` ditulis langsung 20%,** tidak merujuk ke U85/U86. Aplikasi memakai parameter per jenis nasabah. Nilainya saat ini sama. +- Untuk tenor 1 bulan atau nominal < Rp50 jt, Excel menghasilkan `#VALUE!`. Aplikasi menampilkan "Tanpa cashback" beserta alasannya. +- Parameter **LPS 3,75% (U79) tidak dipakai** di rumus mana pun. +- Giro dengan nominal tepat Rp50.000.000 mendapat rate 0% (tier "≤ 50 jt"), padahal cashback mulai berlaku di nominal yang sama. diff --git a/error_log b/error_log new file mode 100644 index 0000000..cd2eae3 --- /dev/null +++ b/error_log @@ -0,0 +1,117 @@ +[12-Oct-2026 08:45:18 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:45:18 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:45:20 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:45:20 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:45:23 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:45:23 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:49:50 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:49:50 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:50:56 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:50:56 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:55:01 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:55:01 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:55:46 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:55:46 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:55:49 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:55:49 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:56:32 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:56:32 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:56:35 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:56:35 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:56:40 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:56:40 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:56:46 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:56:46 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:56:51 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:56:51 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:57:02 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:57:02 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:58:42 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:58:42 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 08:59:54 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 08:59:54 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:03:54 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:03:54 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:05:24 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:05:24 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:05:27 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:05:27 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:07:47 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:07:47 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:08:53 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:08:53 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:08:54 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:08:54 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:09:23 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:09:23 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:09:50 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:09:50 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:10:03 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:10:03 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:10:52 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:10:52 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:15:17 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:15:17 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:16:17 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:16:17 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:22:49 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:22:49 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:24:04 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:24:04 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:26:38 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:26:38 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 09:26:46 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 09:26:46 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 10:36:44 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 10:36:44 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 10:39:31 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 10:39:31 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 10:39:33 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 10:39:33 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 10:39:37 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[12-Oct-2026 10:39:37 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[12-Oct-2026 13:10:02 Asia/Jayapura] [SERUBI] load_params gagal: could not find driver +[12-Oct-2026 13:10:03 Asia/Jayapura] [SERUBI] load_params gagal: could not find driver +[12-Oct-2026 13:10:04 Asia/Jayapura] [SERUBI] load_params gagal: could not find driver +[12-Oct-2026 13:10:05 Asia/Jayapura] [SERUBI] load_params gagal: could not find driver +[12-Oct-2026 13:10:11 Asia/Jayapura] [SERUBI] load_params gagal: could not find driver +[12-Oct-2026 13:10:13 Asia/Jayapura] [SERUBI] load_params gagal: could not find driver +[12-Oct-2026 13:12:37 Asia/Jayapura] [SERUBI] load_params gagal: could not find driver +[12-Oct-2026 04:20:24 UTC] PHP Fatal error: Uncaught Error: Class "PDO" not found in /home/k5551916/serubi.bankpapua.co.id/dbtest.php:5 +Stack trace: +#0 {main} + thrown in /home/k5551916/serubi.bankpapua.co.id/dbtest.php on line 5 +[12-Oct-2026 04:20:32 UTC] PHP Fatal error: Uncaught Error: Class "PDO" not found in /home/k5551916/serubi.bankpapua.co.id/dbtest.php:5 +Stack trace: +#0 {main} + thrown in /home/k5551916/serubi.bankpapua.co.id/dbtest.php on line 5 +[12-Oct-2026 04:20:57 UTC] PHP Fatal error: Uncaught Error: Class "PDO" not found in /home/k5551916/serubi.bankpapua.co.id/dbtest.php:5 +Stack trace: +#0 {main} + thrown in /home/k5551916/serubi.bankpapua.co.id/dbtest.php on line 5 +[13-Oct-2026 06:34:09 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:34:09 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:34:30 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:34:30 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:34:36 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:34:36 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:51:41 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:51:41 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:51:41 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:51:41 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:51:43 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:51:43 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:51:45 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:51:45 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:51:45 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:51:45 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:51:48 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:51:48 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:54:29 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:54:29 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:54:31 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:54:31 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:54:32 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:54:32 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 +[13-Oct-2026 06:54:33 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 2 +[13-Oct-2026 06:54:33 Asia/Jakarta] PHP Parse error: syntax error, unexpected '?' in /home/k5551916/serubi.bankpapua.co.id/index.php on line 20 diff --git a/includes/admin_actions.php b/includes/admin_actions.php new file mode 100644 index 0000000..9902e70 --- /dev/null +++ b/includes/admin_actions.php @@ -0,0 +1,498 @@ + [...], 'draft' => [...]] so the + * form can be re-rendered with what the admin typed. + */ + +const MAX_TIER_ROWS = 30; +const MAX_NOMINAL = 1e15; // Rp1.000 triliun; also keeps values inside DECIMAL(20,0) + +/** Rejects oversized or malformed array input before any processing. */ +function guard_rows(array $rows, array &$errors, string $key, string $label): array +{ + if (count($rows) > MAX_TIER_ROWS) { + $errors[$key] = "$label: maksimal " . MAX_TIER_ROWS . ' baris.'; + return []; + } + return array_values(array_filter($rows, 'is_array')); +} + +function nominal_error(?float $v, string $label): ?string +{ + return $v !== null && $v > MAX_NOMINAL ? "$label terlalu besar (maksimal Rp1.000 triliun)." : null; +} + +function pct_bounds_error($v, string $label, float $max = 1.0): ?string +{ + if ($v === false || $v === null) { + return "$label wajib diisi dengan angka persen, contoh 0,75."; + } + if ($v < 0 || $v > $max) { + return "$label harus antara 0 dan " . fmt_pct($max) . '%.'; + } + return null; +} + +/** Parses tier rows [['max' => '1.000.000', 'rate' => '0,75'], ...]; last row is "di atas". */ +function parse_tier_rows(array $rows, string $label, array &$errors, string $errKey): array +{ + $rows = guard_rows($rows, $errors, $errKey, $label); + if (isset($errors[$errKey])) { + return []; + } + $rows = array_values(array_filter($rows, fn($r) => trim(input_str($r['max'] ?? '')) !== '' || trim(input_str($r['rate'] ?? '')) !== '')); + if (!$rows) { + $errors[$errKey] = "$label: minimal satu baris."; + return []; + } + $out = []; + $prev = -1; + $last = count($rows) - 1; + foreach ($rows as $i => $r) { + $max = $i === $last ? null : parse_idr($r['max'] ?? ''); + $rate = parse_pct($r['rate'] ?? ''); + $n = $i + 1; + if ($e = nominal_error($max, "$label baris $n")) { + $errors[$errKey] = $e; + } elseif ($i !== $last && $max === null) { + $errors[$errKey] = "$label baris $n: isi batas nominal (hanya baris terakhir yang tanpa batas)."; + } elseif ($max !== null && $max <= $prev) { + $errors[$errKey] = "$label baris $n: batas nominal harus lebih besar dari baris sebelumnya."; + } elseif ($err = pct_bounds_error($rate, "$label baris $n")) { + $errors[$errKey] = $err; + } + if ($max !== null) { + $prev = $max; + } + $out[] = ['max' => $max, 'rate' => $rate === false ? null : $rate]; + } + return $out; +} + +function changed_settings(array $old, array $new): array +{ + $diff = []; + foreach ($new as $k => $v) { + if (abs((float)$old[$k]['value'] - $v) > 1e-9) { + $diff[$k] = ['label' => $old[$k]['label'], 'unit' => $old[$k]['unit'], 'old' => (float)$old[$k]['value'], 'new' => $v]; + } + } + return $diff; +} + +function action_save_settings(): ?array +{ + $rows = settings_rows(); + $input = (array)($_POST['s'] ?? []); + $errors = []; + $values = []; + foreach ($rows as $key => $row) { + $raw = input_str($input[$key] ?? ''); + if ($row['unit'] === 'pct') { + $v = parse_pct($raw); + if ($e = pct_bounds_error($v, $row['label'])) { + $errors["s.$key"] = $e; + continue; + } + } elseif ($row['unit'] === 'idr') { + $v = parse_idr($raw); + if ($v === null) { + $errors["s.$key"] = $row['label'] . ' wajib diisi.'; + continue; + } + if ($e = nominal_error($v, $row['label'])) { + $errors["s.$key"] = $e; + continue; + } + } else { + $v = is_numeric(str_replace(',', '.', $raw)) ? (float)str_replace(',', '.', $raw) : null; + if ($v === null || $v <= 0 || $v > 1000) { + $errors["s.$key"] = $row['label'] . ' harus angka lebih dari 0.'; + continue; + } + } + $values[$key] = $v; + } + if (isset($values['hari_setahun']) && ($values['hari_setahun'] < 360 || $values['hari_setahun'] > 366)) { + $errors['s.hari_setahun'] = 'Hari bunga setahun antara 360 dan 366.'; + } + if (isset($values['hari_sebulan']) && ($values['hari_sebulan'] < 28 || $values['hari_sebulan'] > 31)) { + $errors['s.hari_sebulan'] = 'Hari bunga sebulan antara 28 dan 31.'; + } + if (isset($values['porsi_puab'], $values['porsi_kredit']) && abs($values['porsi_puab'] + $values['porsi_kredit'] - 1) > 1e-9) { + $errors['s.porsi_kredit'] = 'Porsi PUAB + porsi kredit harus 100%.'; + } + if (isset($values['gwm'], $values['kas']) && $values['gwm'] + $values['kas'] >= 1) { + $errors['s.kas'] = 'GWM + kas harus kurang dari 100%.'; + } + if ($errors) { + return ['errors' => $errors, 'draft' => $input]; + } + + $diff = changed_settings($rows, $values); + if (!$diff) { + flash('info', 'Tidak ada perubahan parameter.'); + redirect(base_url('admin/?tab=parameter')); + } + $pdo = db(); + $pdo->beginTransaction(); + $st = $pdo->prepare('UPDATE settings SET value = ? WHERE skey = ?'); + foreach ($diff as $k => $d) { + $st->execute([$d['new'], $k]); + } + audit('param.settings', 'Mengubah ' . count($diff) . ' parameter: ' . implode(', ', array_column($diff, 'label')), $diff); + $pdo->commit(); + flash('ok', count($diff) . ' parameter disimpan. Kalkulator langsung memakai nilai baru.'); + redirect(base_url('admin/?tab=parameter')); +} + +function action_save_products(): ?array +{ + $input = (array)($_POST['tiers'] ?? []); + $errors = []; + $parsed = []; + foreach (SERUBI_PRODUCTS as $key => $label) { + $parsed[$key] = parse_tier_rows((array)($input[$key] ?? []), $label, $errors, "tiers.$key"); + } + if ($errors) { + return ['errors' => $errors, 'draft' => $input]; + } + $before = load_params()['productRates']; + $after = []; + foreach ($parsed as $k => $rows) { + $after[$k] = array_map(fn($r) => ['max' => $r['max'], 'rate' => $r['rate']], $rows); + } + $changed = array_keys(array_filter($after, fn($rows, $k) => json_encode($rows) !== json_encode($before[$k]), ARRAY_FILTER_USE_BOTH)); + if (!$changed) { + flash('info', 'Tidak ada perubahan rate produk.'); + redirect(base_url('admin/?tab=produk')); + } + $pdo = db(); + $pdo->beginTransaction(); + $del = $pdo->prepare('DELETE FROM product_rates WHERE product = ?'); + $ins = $pdo->prepare('INSERT INTO product_rates (product, max_nominal, rate, sort) VALUES (?,?,?,?)'); + foreach ($changed as $k) { + $del->execute([$k]); + foreach ($after[$k] as $i => $r) { + $ins->execute([$k, $r['max'], $r['rate'], $i]); + } + } + $names = array_map(fn($k) => SERUBI_PRODUCTS[$k], $changed); + audit('param.produk', 'Mengubah rate ' . implode(', ', $names), + array_combine($changed, array_map(fn($k) => ['old' => $before[$k], 'new' => $after[$k]], $changed))); + $pdo->commit(); + flash('ok', 'Rate ' . implode(', ', $names) . ' disimpan.'); + redirect(base_url('admin/?tab=produk')); +} + +function action_save_deposito(): ?array +{ + $params = load_params(); + $tenors = $params['tenors']; + $bandsIn = array_values((array)($_POST['bands'] ?? [])); + $ratesIn = array_values((array)($_POST['rates'] ?? [])); + $errors = []; + if (count($bandsIn) > MAX_TIER_ROWS || count($ratesIn) > MAX_TIER_ROWS) { + return ['errors' => ['deposito' => 'Maksimal ' . MAX_TIER_ROWS . ' kelompok nominal.'], 'draft' => null]; + } + $bandsIn = array_map(fn($b) => is_array($b) ? '' : (string)$b, $bandsIn); + $ratesIn = array_map(fn($r) => is_array($r) ? $r : [], $ratesIn); + $count = count($bandsIn); + if ($count === 0) { + $errors['deposito'] = 'Minimal satu kelompok nominal.'; + } + $bands = []; + $matrix = []; + $prev = -1; + foreach ($bandsIn as $i => $raw) { + $max = $i === $count - 1 ? null : parse_idr($raw); + if ($e = nominal_error($max, 'Baris ' . ($i + 1))) { + $errors['deposito'] = $e; + } elseif ($i !== $count - 1 && $max === null) { + $errors['deposito'] = 'Baris ' . ($i + 1) . ': isi batas nominal (hanya baris terakhir yang tanpa batas).'; + } elseif ($max !== null && $max <= $prev) { + $errors['deposito'] = 'Baris ' . ($i + 1) . ': batas nominal harus lebih besar dari baris sebelumnya.'; + } + if ($max !== null) { + $prev = $max; + } + $bands[] = $max; + foreach ($tenors as $t) { + $v = parse_pct($ratesIn[$i][$t] ?? ''); + if ($e = pct_bounds_error($v, 'Rate ' . $t . ' bulan baris ' . ($i + 1))) { + $errors['deposito'] = $e; + } + $matrix[$i][$t] = $v === false ? null : $v; + } + } + if ($errors) { + return ['errors' => $errors, 'draft' => ['bands' => $bandsIn, 'rates' => $ratesIn]]; + } + $before = ['bands' => $params['depositoBands'], 'rates' => $params['depositoRates']]; + $afterRates = []; + foreach ($tenors as $t) { + foreach ($bands as $i => $max) { + $afterRates[$t][] = ['max' => $max, 'rate' => $matrix[$i][$t]]; + } + } + if (json_encode($before['rates']) === json_encode($afterRates)) { + flash('info', 'Tidak ada perubahan rate deposito.'); + redirect(base_url('admin/?tab=deposito')); + } + $pdo = db(); + $pdo->beginTransaction(); + $pdo->exec('DELETE FROM deposito_bands'); + $insB = $pdo->prepare('INSERT INTO deposito_bands (max_nominal, sort) VALUES (?,?)'); + $insR = $pdo->prepare('INSERT INTO deposito_rates (band_id, tenor, rate) VALUES (?,?,?)'); + foreach ($bands as $i => $max) { + $insB->execute([$max, $i]); + $id = (int)$pdo->lastInsertId(); + foreach ($tenors as $t) { + $insR->execute([$id, $t, $matrix[$i][$t]]); + } + } + audit('param.deposito', 'Mengubah tabel rate deposito', ['old' => $before['rates'], 'new' => $afterRates]); + $pdo->commit(); + flash('ok', 'Rate deposito disimpan.'); + redirect(base_url('admin/?tab=deposito')); +} + +function action_save_cashback(): ?array +{ + $params = load_params(); + $errors = []; + $rowsIn = guard_rows((array)($_POST['cb'] ?? []), $errors, 'cb', 'Porsi cashback'); + if ($errors) { + return ['errors' => $errors, 'draft' => null]; + } + $tenorIn = array_map('intval', array_keys((array)($_POST['cbtenor'] ?? []))); + $errors = []; + $tiers = []; + $seen = []; + foreach ($rowsIn as $i => $r) { + if (trim(input_str($r['min'] ?? '')) === '' && trim(input_str($r['share'] ?? '')) === '') { + continue; + } + $min = parse_idr($r['min'] ?? ''); + $share = parse_pct($r['share'] ?? ''); + $n = $i + 1; + if ($min === null || $min <= 0) { + $errors['cb'] = "Baris $n: nominal minimal wajib diisi."; + } elseif ($e = nominal_error($min, "Baris $n")) { + $errors['cb'] = $e; + } elseif (isset($seen[(string)$min])) { + $errors['cb'] = "Baris $n: nominal minimal sama dengan baris lain."; + } elseif ($e = pct_bounds_error($share, "Porsi nasabah baris $n")) { + $errors['cb'] = $e; + } + $seen[(string)$min] = true; + $tiers[] = ['min' => $min, 'nasabah' => $share === false ? null : $share]; + } + if (!$tiers && !$errors) { + $errors['cb'] = 'Minimal satu tingkat porsi cashback.'; + } + $tenorIn = array_values(array_intersect($params['tenors'], $tenorIn)); + if ($errors) { + return ['errors' => $errors, 'draft' => ['cb' => $rowsIn, 'cbtenor' => $tenorIn]]; + } + usort($tiers, fn($a, $b) => $a['min'] <=> $b['min']); + $before = ['tiers' => $params['cashbackTiers'], 'tenors' => $params['cashbackTenors']]; + $after = ['tiers' => $tiers, 'tenors' => $tenorIn]; + if (json_encode($before) === json_encode($after)) { + flash('info', 'Tidak ada perubahan porsi cashback.'); + redirect(base_url('admin/?tab=cashback')); + } + $pdo = db(); + $pdo->beginTransaction(); + $pdo->exec('DELETE FROM cashback_tiers'); + $ins = $pdo->prepare('INSERT INTO cashback_tiers (min_nominal, share_nasabah) VALUES (?,?)'); + foreach ($tiers as $t) { + $ins->execute([$t['min'], $t['nasabah']]); + } + $up = $pdo->prepare('UPDATE tenors SET cashback = ? WHERE months = ?'); + foreach ($params['tenors'] as $m) { + $up->execute([in_array($m, $tenorIn, true) ? 1 : 0, $m]); + } + audit('param.cashback', 'Mengubah porsi cashback / tenor program', ['old' => $before, 'new' => $after]); + $pdo->commit(); + flash('ok', 'Porsi cashback disimpan.'); + redirect(base_url('admin/?tab=cashback')); +} + +function action_reset_defaults(): ?array +{ + if (($_POST['confirm'] ?? '') !== 'RESET') { + return ['errors' => ['reset' => 'Ketik RESET untuk konfirmasi.'], 'draft' => []]; + } + $before = load_params(); + db_seed(db(), true); + audit('param.reset', 'Mengembalikan semua parameter ke nilai file Excel', ['old' => $before]); + flash('ok', 'Semua parameter dikembalikan ke nilai file Excel 22092026.'); + redirect(base_url('admin/?tab=parameter')); +} + +/** + * Managing other admins is reserved for super admins and needs the acting + * admin's own password (re-authentication). Returns an error response or null. + */ +function user_admin_guard(array $me, string $errKey, int $targetId = 0): ?array +{ + if (!is_super_admin($me)) { + http_response_code(403); + return ['errors' => [$errKey => 'Hanya super admin yang dapat mengelola akun admin lain.', 'user_id' => $targetId], 'draft' => []]; + } + if (!reauth_ok($me, input_str($_POST['confirm_password'] ?? ''))) { + return ['errors' => [$errKey => 'Password Anda (konfirmasi) salah.', 'user_id' => $targetId], 'draft' => []]; + } + return null; +} + +function admin_target(int $id): ?array +{ + $st = db()->prepare('SELECT * FROM admins WHERE id = ?'); + $st->execute([$id]); + return $st->fetch() ?: null; +} + +function active_super_admins(): int +{ + return (int)db()->query("SELECT COUNT(*) FROM admins WHERE role = 'super_admin' AND is_active = 1")->fetchColumn(); +} + +function action_user_create(array $me): ?array +{ + $u = strtolower(trim(input_str($_POST['username'] ?? ''))); + $n = trim(input_str($_POST['full_name'] ?? '')); + if ($denied = user_admin_guard($me, 'user_new')) { + $denied['draft'] = ['username' => $u, 'full_name' => $n]; + return $denied; + } + $p = input_str($_POST['password'] ?? ''); + $role = input_str($_POST['role'] ?? '') === 'super_admin' ? 'super_admin' : 'admin'; + $err = validate_new_admin($u, $n, $p, input_str($_POST['password_confirm'] ?? '')); + if (!$err) { + $st = db()->prepare('SELECT COUNT(*) FROM admins WHERE username = ?'); + $st->execute([$u]); + if ((int)$st->fetchColumn() > 0) { + $err = 'Username sudah dipakai.'; + } + } + if ($err) { + return ['errors' => ['user_new' => $err], 'draft' => ['username' => $u, 'full_name' => $n]]; + } + // The new admin replaces this initial password and sets up OTP at the first login. + db()->prepare('INSERT INTO admins (username, full_name, role, password_hash, must_change_password) VALUES (?,?,?,?,1)') + ->execute([$u, $n, $role, password_hash($p, PASSWORD_DEFAULT)]); + audit('user.create', "Menambah admin $u (" . ($role === 'super_admin' ? 'super admin' : 'admin') . ')'); + flash('ok', "Admin $u ditambahkan. Password awal wajib diganti dan OTP diaktifkan saat login pertama."); + redirect(base_url('admin/?tab=pengguna')); +} + +function action_user_toggle(array $me): ?array +{ + $id = (int)($_POST['id'] ?? 0); + if ($denied = user_admin_guard($me, 'user', $id)) { + return $denied; + } + $row = admin_target($id); + if (!$row) { + return ['errors' => ['user' => 'Admin tidak ditemukan.'], 'draft' => []]; + } + if ($id === $me['id']) { + return ['errors' => ['user' => 'Anda tidak dapat menonaktifkan akun sendiri.'], 'draft' => []]; + } + $active = (int)$row['is_active'] ? 0 : 1; + if (!$active && $row['role'] === 'super_admin' && active_super_admins() <= 1) { + return ['errors' => ['user' => 'Super admin aktif terakhir tidak dapat dinonaktifkan.', 'user_id' => $id], 'draft' => []]; + } + db()->prepare('UPDATE admins SET is_active = ? WHERE id = ?')->execute([$active, $id]); + audit('user.toggle', ($active ? 'Mengaktifkan' : 'Menonaktifkan') . ' admin ' . $row['username']); + flash('ok', 'Admin ' . $row['username'] . ($active ? ' diaktifkan.' : ' dinonaktifkan.')); + redirect(base_url('admin/?tab=pengguna')); +} + +function action_user_password(array $me): ?array +{ + $id = (int)($_POST['id'] ?? 0); + if ($denied = user_admin_guard($me, 'user', $id)) { + return $denied; + } + $p = input_str($_POST['password'] ?? ''); + $row = admin_target($id); + $err = $row ? validate_password($p, input_str($_POST['password_confirm'] ?? ''), (string)$row['username']) : 'Admin tidak ditemukan.'; + if ($err) { + return ['errors' => ['user' => $err, 'user_id' => $id], 'draft' => []]; + } + // Temporary password: the owner must replace it at the next login (the new hash also ends their sessions). + db()->prepare('UPDATE admins SET password_hash = ?, must_change_password = 1 WHERE id = ?')->execute([password_hash($p, PASSWORD_DEFAULT), $id]); + audit('user.password', 'Me-reset password admin ' . $row['username']); + flash('ok', 'Password ' . $row['username'] . ' di-reset. Pemilik akun wajib menggantinya saat login berikutnya.'); + redirect(base_url('admin/?tab=pengguna')); +} + +function action_user_role(array $me): ?array +{ + $id = (int)($_POST['id'] ?? 0); + if ($denied = user_admin_guard($me, 'user', $id)) { + return $denied; + } + $row = admin_target($id); + if (!$row || $id === $me['id']) { + return ['errors' => ['user' => $row ? 'Anda tidak dapat mengubah peran akun sendiri.' : 'Admin tidak ditemukan.', 'user_id' => $id], 'draft' => []]; + } + $role = $row['role'] === 'super_admin' ? 'admin' : 'super_admin'; + if ($role === 'admin' && (int)$row['is_active'] && active_super_admins() <= 1) { + return ['errors' => ['user' => 'Harus ada minimal satu super admin aktif.', 'user_id' => $id], 'draft' => []]; + } + db()->prepare('UPDATE admins SET role = ? WHERE id = ?')->execute([$role, $id]); + $label = $role === 'super_admin' ? 'super admin' : 'admin'; + audit('user.role', 'Mengubah peran ' . $row['username'] . ' menjadi ' . $label); + flash('ok', 'Peran ' . $row['username'] . ' diubah menjadi ' . $label . '.'); + redirect(base_url('admin/?tab=pengguna')); +} + +function action_user_reset_otp(array $me): ?array +{ + $id = (int)($_POST['id'] ?? 0); + if ($denied = user_admin_guard($me, 'user', $id)) { + return $denied; + } + $row = admin_target($id); + if (!$row || $id === $me['id']) { + return ['errors' => ['user' => $row ? 'OTP akun sendiri tidak dapat di-reset dari sini.' : 'Admin tidak ditemukan.', 'user_id' => $id], 'draft' => []]; + } + // Lost phone: the owner sets up a new authenticator at the next login. + db()->prepare('UPDATE admins SET otp_secret = NULL, otp_enabled = 0, otp_last_step = NULL, recovery_codes = NULL WHERE id = ?')->execute([$id]); + audit('user.otp', 'Me-reset OTP admin ' . $row['username']); + flash('ok', 'OTP ' . $row['username'] . ' di-reset. Authenticator baru dipasang saat login berikutnya.'); + redirect(base_url('admin/?tab=pengguna')); +} + +function action_own_password(array $me): ?array +{ + $st = db()->prepare('SELECT password_hash FROM admins WHERE id = ?'); + $st->execute([$me['id']]); + $hash = (string)$st->fetchColumn(); + $err = null; + if (!password_verify(input_str($_POST['current'] ?? ''), $hash)) { + $err = 'Password saat ini salah.'; + } else { + $err = validate_password(input_str($_POST['password'] ?? ''), input_str($_POST['password_confirm'] ?? ''), $me['username']); + } + if ($err) { + return ['errors' => ['own' => $err], 'draft' => []]; + } + $newHash = password_hash(input_str($_POST['password']), PASSWORD_DEFAULT); + db()->prepare('UPDATE admins SET password_hash = ?, must_change_password = 0 WHERE id = ?')->execute([$newHash, $me['id']]); + // keep this session, end every other session of this account + session_regenerate_id(true); + $_SESSION['pwv'] = password_fingerprint($newHash); + audit('user.password', 'Mengganti password sendiri'); + flash('ok', 'Password Anda diganti.'); + redirect(base_url('admin/?tab=pengguna')); +} diff --git a/includes/auth.php b/includes/auth.php new file mode 100644 index 0000000..c3ab4a1 --- /dev/null +++ b/includes/auth.php @@ -0,0 +1,306 @@ + SESSION_IDLE_MIN * 60 + || $now - ($_SESSION['started'] ?? 0) > SESSION_ABSOLUTE_HOURS * 3600) { + logout_admin(); + return null; + } + if ($now - ($_SESSION['rotated'] ?? 0) > SESSION_ROTATE_MIN * 60) { + session_regenerate_id(true); + $_SESSION['rotated'] = $now; + } + $_SESSION['seen'] = $now; + return $a; +} + +/** + * Why a logged-in session is no longer valid, or null when it is: the account + * may have been deactivated, its password changed, or it logged in elsewhere. + */ +/** The admin's current database row (read once per request). */ +function admin_db_row(int $id, bool $fresh = false): ?array +{ + static $rows = []; + if ($fresh || !array_key_exists($id, $rows)) { + $st = db()->prepare('SELECT * FROM admins WHERE id = ?'); + $st->execute([$id]); + $rows[$id] = $st->fetch() ?: null; + } + return $rows[$id]; +} + +function admin_session_problem(array $a): ?string +{ + $row = admin_db_row($a['id']); + if (!$row || !(int)$row['is_active']) { + return 'inactive'; + } + if (!hash_equals((string)($_SESSION['pwv'] ?? ''), password_fingerprint($row['password_hash']))) { + return 'password'; + } + if (!hash_equals((string)($_SESSION['login_at'] ?? ''), (string)$row['last_login_at'])) { + return 'elsewhere'; // one active session per account: the newest login wins + } + return null; +} + +function require_admin(): array +{ + send_security_headers(true); + $a = current_admin(); + if (!$a) { + redirect(base_url('admin/login.php')); + } + if ($reason = admin_session_problem($a)) { + logout_admin(); + redirect(base_url('admin/login.php?reason=' . $reason)); + } + // Role and pending password change are read from the database on every + // request, so a change by a super admin applies immediately. + $row = admin_db_row($a['id']); + $a['role'] = $row['role'] === 'super_admin' ? 'super_admin' : 'admin'; + $a['must_change'] = (int)$row['must_change_password'] === 1; + return $a; +} + +function is_super_admin(array $me): bool +{ + return ($me['role'] ?? '') === 'super_admin'; +} + +/** + * Re-authentication before managing other admins: the acting admin types + * their own password. Wrong answers count like failed logins (IP lockout). + */ +function reauth_ok(array $me, string $password): bool +{ + $row = admin_db_row($me['id'], true); + $ok = $row && password_verify($password, $row['password_hash']); + if (!$ok) { + db()->prepare('INSERT INTO login_attempts (ip, username, success) VALUES (?,?,0)') + ->execute([client_ip(), substr('reauth:' . $me['username'], 0, 50)]); + } + return $ok; +} + +/** + * The logged-in admin on a public page, or null. Never redirects, and never + * starts a session for visitors who have no admin session cookie. + */ +function viewing_admin(): ?array +{ + if (empty($_COOKIE[SESSION_COOKIE])) { + return null; + } + $a = current_admin(); + if ($a && admin_session_problem($a)) { + logout_admin(); + return null; + } + return $a; +} + +function admin_count(): int +{ + return (int)db()->query('SELECT COUNT(*) FROM admins')->fetchColumn(); +} + +/** + * True when this IP has too many failures. There is deliberately no lockout + * per username (anyone could lock an admin out by guessing on purpose); the + * CAPTCHA and the OTP step slow down distributed guessing instead, and the + * owner is told about failed attempts at the next login. + */ +function login_locked(): bool +{ + $st = db()->prepare('SELECT COUNT(*) FROM login_attempts WHERE ip = ? AND success = 0 AND created_at > (NOW() - INTERVAL ' . LOGIN_WINDOW_MIN . ' MINUTE)'); + $st->execute([client_ip()]); + return (int)$st->fetchColumn() >= LOGIN_MAX_FAILS; +} + +/** Failed password/OTP attempts recorded for a username since a moment (null = last 30 days). */ +function failed_attempts_since(string $username, ?string $since): int +{ + $st = db()->prepare('SELECT COUNT(*) FROM login_attempts WHERE username IN (?, ?) AND success = 0 AND created_at > ?'); + $st->execute([substr($username, 0, 50), substr('otp:' . $username, 0, 50), $since ?: date('Y-m-d H:i:s', time() - 30 * 86400)]); + return (int)$st->fetchColumn(); +} + +/** Keeps the attempts table small; called on login. */ +function prune_login_attempts(): void +{ + if (random_int(1, 50) === 1) { + db()->exec('DELETE FROM login_attempts WHERE created_at < (NOW() - INTERVAL 30 DAY)'); + } +} + +/** Step 1 of the login: the admin row when username and password are right, else null. */ +function verify_credentials(string $username, string $password): ?array +{ + $st = db()->prepare('SELECT * FROM admins WHERE username = ? AND is_active = 1'); + $st->execute([$username]); + $row = $st->fetch(); + // Verify against a dummy hash when the user does not exist to keep timing similar. + static $dummy = null; + if ($dummy === null) { + $dummy = password_hash(bin2hex(random_bytes(8)), PASSWORD_DEFAULT); + } + prune_login_attempts(); + $ok = password_verify($password, $row['password_hash'] ?? $dummy) && $row; + db()->prepare('INSERT INTO login_attempts (ip, username, success) VALUES (?,?,?)') + ->execute([client_ip(), substr($username, 0, 50), $ok ? 1 : 0]); + if (!$ok) { + // A burst of failures on one username is recorded for the admins (no lockout, see login_locked()). + if (failed_attempts_since($username, date('Y-m-d H:i:s', time() - LOGIN_WINDOW_MIN * 60)) === LOGIN_MAX_FAILS_USER) { + db()->prepare("INSERT INTO audit_log (action, summary, ip) VALUES ('login.suspicious', ?, ?)") + ->execute(['Percobaan login gagal beruntun untuk username ' . substr($username, 0, 50), client_ip()]); + } + return null; + } + if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) { + $row['password_hash'] = password_hash($password, PASSWORD_DEFAULT); + db()->prepare('UPDATE admins SET password_hash = ? WHERE id = ?')->execute([$row['password_hash'], $row['id']]); + } + return $row; +} + +/** + * After a correct password the admin is NOT logged in yet: the session only + * remembers who passed step 1. Mode 'verify' asks for the OTP code, mode + * 'enroll' sets up the authenticator first (2FA is mandatory). + */ +function begin_second_factor(array $row): void +{ + start_session(); + session_regenerate_id(true); + $_SESSION['pre_auth'] = [ + 'id' => (int)$row['id'], + 'mode' => (int)$row['otp_enabled'] === 1 ? 'verify' : 'enroll', + 'exp' => time() + PRE_AUTH_TTL, + ]; +} + +/** [admin row, mode] of a login waiting for its second factor, or null. */ +function pending_login(): ?array +{ + start_session(); + $p = $_SESSION['pre_auth'] ?? null; + if (!$p || time() > $p['exp']) { + unset($_SESSION['pre_auth']); + return null; + } + $row = admin_db_row($p['id'], true); + if (!$row || !(int)$row['is_active']) { + unset($_SESSION['pre_auth']); + return null; + } + return [$row, $p['mode']]; +} + +/** Wrong OTP codes for this username within the window. */ +function otp_failures(string $username): int +{ + $st = db()->prepare('SELECT COUNT(*) FROM login_attempts WHERE username = ? AND success = 0 AND created_at > (NOW() - INTERVAL ' . LOGIN_WINDOW_MIN . ' MINUTE)'); + $st->execute([substr('otp:' . $username, 0, 50)]); + return (int)$st->fetchColumn(); +} + +function record_otp_attempt(string $username, bool $ok): void +{ + db()->prepare('INSERT INTO login_attempts (ip, username, success) VALUES (?,?,?)') + ->execute([client_ip(), substr('otp:' . $username, 0, 50), $ok ? 1 : 0]); +} + +/** Final step: creates the admin session after password + second factor. */ +function complete_login(array $row, string $method): void +{ + unset($_SESSION['pre_auth']); + $failed = failed_attempts_since($row['username'], $row['last_login_at']); + // The login time doubles as the session marker: any later login on another + // device changes it and ends this session (see require_admin()). + $row['last_login_at'] = date('Y-m-d H:i:s'); + db()->prepare('UPDATE admins SET last_login_at = ? WHERE id = ?')->execute([$row['last_login_at'], $row['id']]); + login_admin($row); + audit('login', 'Masuk ke halaman admin (' . $method . ')'); + if ($failed > 0) { + flash('info', "Ada $failed percobaan login gagal pada akun Anda sejak login terakhir. Bila itu bukan Anda, segera ganti password dan hubungi super admin."); + } +} + +function login_admin(array $row): void +{ + start_session(); + session_regenerate_id(true); + $_SESSION['admin'] = ['id' => (int)$row['id'], 'username' => $row['username'], 'name' => $row['full_name']]; + $_SESSION['pwv'] = password_fingerprint($row['password_hash']); + $_SESSION['login_at'] = (string)$row['last_login_at']; + $_SESSION['seen'] = $_SESSION['started'] = $_SESSION['rotated'] = time(); + unset($_SESSION['csrf']); // fresh CSRF token for the authenticated session +} + +function logout_admin(): void +{ + start_session(); + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $p = session_get_cookie_params(); + setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'] ?? '', (bool)$p['secure'], true); + } + session_destroy(); +} + +/** Returns an error message or null. */ +function validate_new_admin(string $username, string $name, string $password, string $confirm): ?string +{ + if (!preg_match('/^[a-z0-9._-]{3,50}$/', $username)) { + return 'Username 3–50 karakter: huruf kecil, angka, titik, minus, atau garis bawah.'; + } + if (trim($name) === '' || mb_strlen($name) > 120 || preg_match('/[[:cntrl:]<>]/u', $name)) { + return 'Nama lengkap wajib diisi, maksimal 120 karakter, tanpa karakter khusus < >.'; + } + return validate_password($password, $confirm, $username); +} + +function validate_password(string $password, string $confirm, string $username = ''): ?string +{ + if (mb_strlen($password) < PASSWORD_MIN || strlen($password) > 72) { + return 'Password ' . PASSWORD_MIN . '–72 karakter.'; + } + if (!preg_match('/[A-Za-z]/', $password) || !preg_match('/\d/', $password) || !preg_match('/[^A-Za-z\d]/', $password)) { + return 'Password harus berisi huruf, angka, dan simbol.'; + } + if ($username !== '' && stripos($password, $username) !== false) { + return 'Password tidak boleh memuat username.'; + } + if ($password !== $confirm) { + return 'Konfirmasi password tidak sama.'; + } + return null; +} diff --git a/includes/captcha.php b/includes/captcha.php new file mode 100644 index 0000000..d0ad41d --- /dev/null +++ b/includes/captcha.php @@ -0,0 +1,95 @@ + (string)($a + $b), 'question' => "Berapa $a + $b?", 'exp' => time() + CAPTCHA_TTL]; + return; + } + $code = ''; + for ($i = 0; $i < CAPTCHA_LENGTH; $i++) { + $code .= CAPTCHA_ALPHABET[random_int(0, strlen(CAPTCHA_ALPHABET) - 1)]; + } + $_SESSION['captcha'] = ['code' => $code, 'exp' => time() + CAPTCHA_TTL]; +} + +/** Text question when the image cannot be drawn, else null. */ +function captcha_question(): ?string +{ + return $_SESSION['captcha']['question'] ?? null; +} + +/** Checks the answer and always consumes the challenge (one attempt per image). */ +function captcha_check(string $answer): bool +{ + start_session(); + $c = $_SESSION['captcha'] ?? null; + unset($_SESSION['captcha']); + if (!$c || time() > $c['exp']) { + return false; + } + $answer = strtoupper(preg_replace('/\s+/', '', $answer)); + return $answer !== '' && hash_equals($c['code'], $answer); +} + +/** Outputs the PNG for the current challenge. */ +function captcha_render(): void +{ + start_session(); + $code = (string)($_SESSION['captcha']['code'] ?? ''); + $w = 200; + $h = 64; + $img = imagecreatetruecolor($w, $h); + imagefilledrectangle($img, 0, 0, $w, $h, imagecolorallocate($img, 244, 246, 250)); + // background noise + for ($i = 0; $i < 9; $i++) { + $c = imagecolorallocate($img, random_int(140, 200), random_int(150, 205), random_int(170, 215)); + imageline($img, random_int(0, $w), random_int(0, $h), random_int(0, $w), random_int(0, $h), $c); + } + // each glyph is drawn small (built-in font), then scaled and jittered + $x = 14; + foreach (str_split($code) as $ch) { + $g = imagecreatetruecolor(10, 16); + $bg = imagecolorallocate($g, 244, 246, 250); + imagefill($g, 0, 0, $bg); + imagecolortransparent($g, $bg); + imagestring($g, 5, 1, 0, $ch, imagecolorallocate($g, random_int(10, 60), random_int(30, 70), random_int(90, 140))); + $size = random_int(30, 38); + imagecopyresized($img, $g, $x, random_int(4, $h - $size - 10) + 6, 0, 0, (int)($size * 0.66), $size, 10, 16); + $x += random_int(31, 36); + } + // foreground noise over the text + for ($i = 0; $i < 3; $i++) { + imagesetthickness($img, 2); + imageline($img, 0, random_int(10, $h - 10), $w, random_int(10, $h - 10), imagecolorallocate($img, 60, 80, 130)); + } + for ($i = 0; $i < 220; $i++) { + imagesetpixel($img, random_int(0, $w - 1), random_int(0, $h - 1), imagecolorallocate($img, random_int(60, 160), random_int(60, 160), random_int(60, 160))); + } + header('Content-Type: image/png'); + header('Cache-Control: no-store, max-age=0'); + imagepng($img); +} diff --git a/includes/db.php b/includes/db.php new file mode 100644 index 0000000..bf58d80 --- /dev/null +++ b/includes/db.php @@ -0,0 +1,128 @@ +exec("SET time_zone = '" . (new DateTime())->format('P') . "'"); + return $pdo; +} + +function db_connect(array $c, bool $withDatabase): PDO +{ + $dsn = sprintf('mysql:host=%s;port=%d;charset=utf8mb4', $c['host'], $c['port']); + if ($withDatabase) { + $dsn .= ';dbname=' . str_replace('`', '', $c['name']); + } + return new PDO($dsn, $c['user'], $c['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); +} + +/** Runs a trusted SQL file; statements end with ";" at the end of a line (no splitting inside literals). */ +function db_run_sql_file(PDO $pdo, string $file): void +{ + $sql = preg_replace('/^\s*--.*$/m', '', (string)file_get_contents($file)); + foreach (preg_split('/;\s*$/m', $sql) as $stmt) { + if (trim($stmt) !== '') { + $pdo->exec($stmt); + } + } +} + +/** CLI only (database/migrate.php): creates the tables and seed data on an empty database. */ +function db_migrate(PDO $pdo): void +{ + $exists = $pdo->query("SHOW TABLES LIKE 'login_attempts'")->fetchColumn(); + if (!$exists) { + db_run_sql_file($pdo, __DIR__ . '/../database/schema.sql'); + } + if ((int)$pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn() === 0) { + db_seed($pdo); + } + db_clean_descriptions($pdo); +} + +/** Removes workbook cell references like " (COB!F28)" from older seeded descriptions (runs until none are left). */ +function db_clean_descriptions(PDO $pdo): void +{ + $rows = $pdo->query("SELECT skey, description FROM settings WHERE description LIKE '%COB!%' OR description LIKE '%CALC!%'")->fetchAll(); + if (!$rows) { + return; + } + $st = $pdo->prepare('UPDATE settings SET description = ? WHERE skey = ?'); + foreach ($rows as $r) { + $st->execute([trim(preg_replace('/\s*\((?:COB|CALC)![A-Z0-9:]+\)/', '', (string)$r['description'])), $r['skey']]); + } +} + +/** Seeds parameters from database/defaults.php (the values of the reference workbook). */ +function db_seed(PDO $pdo, bool $wipe = false): void +{ + $d = require __DIR__ . '/../database/defaults.php'; + $pdo->beginTransaction(); + try { + if ($wipe) { + foreach (['deposito_rates', 'deposito_bands', 'product_rates', 'cashback_tiers', 'tenors', 'settings'] as $t) { + $pdo->exec("DELETE FROM $t"); + } + } + $st = $pdo->prepare('INSERT INTO settings (skey, value, label, unit, grp, description, sort) VALUES (?,?,?,?,?,?,?)'); + $i = 0; + foreach ($d['settings'] as $key => [$value, $label, $unit, $grp, $desc]) { + $st->execute([$key, $value, $label, $unit, $grp, $desc, ++$i]); + } + $st = $pdo->prepare('INSERT INTO tenors (months, cashback) VALUES (?,?)'); + foreach ($d['tenors'] as $m => $cb) { + $st->execute([$m, $cb ? 1 : 0]); + } + $st = $pdo->prepare('INSERT INTO product_rates (product, max_nominal, rate, sort) VALUES (?,?,?,?)'); + foreach ($d['product_rates'] as $product => $tiers) { + foreach ($tiers as $i => [$max, $rate]) { + $st->execute([$product, $max, $rate, $i]); + } + } + $band = $pdo->prepare('INSERT INTO deposito_bands (max_nominal, sort) VALUES (?,?)'); + $rate = $pdo->prepare('INSERT INTO deposito_rates (band_id, tenor, rate) VALUES (?,?,?)'); + foreach ($d['deposito_bands'] as $i => $max) { + $band->execute([$max, $i]); + $bandId = (int)$pdo->lastInsertId(); + foreach ($d['deposito_rates'] as $tenor => $rates) { + $rate->execute([$bandId, $tenor, $rates[$i]]); + } + } + $st = $pdo->prepare('INSERT INTO cashback_tiers (min_nominal, share_nasabah) VALUES (?,?)'); + foreach ($d['cashback_tiers'] as [$min, $share]) { + $st->execute([$min, $share]); + } + $pdo->commit(); + } catch (Throwable $e) { + $pdo->rollBack(); + throw $e; + } +} diff --git a/includes/engine.php b/includes/engine.php new file mode 100644 index 0000000..f3f3b43 --- /dev/null +++ b/includes/engine.php @@ -0,0 +1,215 @@ + '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'), + ]; +} diff --git a/includes/helpers.php b/includes/helpers.php new file mode 100644 index 0000000..ebcf4c6 --- /dev/null +++ b/includes/helpers.php @@ -0,0 +1,244 @@ +-. Any file updated on the + * server gets a new URL, so clients never run a stale cached JS/CSS file. + */ +function asset(string $path): string +{ + $file = __DIR__ . '/../' . $path; + $v = app_version() . (is_file($file) ? '-' . base_convert((string)filemtime($file), 10, 36) : ''); + return base_url($path) . '?v=' . rawurlencode($v); +} + +/** Public pages: security headers + always revalidate (so the newest versioned assets load). */ +function send_no_cache_headers(): void +{ + send_security_headers(false); +} + +const SESSION_COOKIE = 'serubi_admin'; + +function start_session(): void +{ + if (session_status() === PHP_SESSION_ACTIVE) { + return; + } + session_name(SESSION_COOKIE); + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => base_url(), + 'httponly' => true, + 'samesite' => 'Strict', + 'secure' => is_https(), + ]); + session_start(); +} + +function csrf_token(): string +{ + start_session(); + if (empty($_SESSION['csrf'])) { + $_SESSION['csrf'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf']; +} + +function csrf_field(): string +{ + return ''; +} + +function csrf_check(): void +{ + start_session(); + $sent = $_POST['csrf'] ?? ''; + if (!is_string($sent) || empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $sent)) { + http_response_code(403); + header('Cache-Control: no-store'); + exit('Sesi formulir kedaluwarsa. Muat ulang halaman lalu coba lagi.'); + } +} + +function flash(?string $type = null, ?string $message = null): ?array +{ + start_session(); + if ($type !== null) { + $_SESSION['flash'] = ['type' => $type, 'message' => $message]; + return null; + } + $f = $_SESSION['flash'] ?? null; + unset($_SESSION['flash']); + return $f; +} + +function redirect(string $url): void +{ + header('Location: ' . $url, true, 303); + exit; +} + +function audit(string $action, string $summary, ?array $changes = null): void +{ + $admin = $_SESSION['admin'] ?? null; + db()->prepare('INSERT INTO audit_log (admin_id, username, action, summary, changes, ip) VALUES (?,?,?,?,?,?)') + ->execute([$admin['id'] ?? null, $admin['username'] ?? null, $action, $summary, + $changes ? json_encode($changes, JSON_UNESCAPED_UNICODE) : null, client_ip()]); + // Every parameter change is audited after it is written: drop the cached parameters here. + if (strpos($action, 'param') === 0) { + params_cache_clear(); + } +} + +// ---- Number formatting / parsing (Indonesian conventions) ------------------- + +function fmt_idr(?float $v): string +{ + return $v === null ? '' : number_format($v, 0, ',', '.'); +} + +/** 0.0075 -> "0,75" */ +function fmt_pct(?float $v): string +{ + if ($v === null) { + return ''; + } + $s = rtrim(rtrim(number_format($v * 100, 4, ',', ''), '0'), ','); + return $s === '' || $s === '-0' ? '0' : $s; +} + +/** "1.000.000" -> 1000000.0; "" -> null */ +function parse_idr($s): ?float +{ + $s = preg_replace('/[^0-9]/', '', input_str($s)); + return $s === '' ? null : (float)$s; +} + +/** "0,75" or "0.75" -> 0.0075; "" -> null; invalid -> false */ +function parse_pct($s) +{ + $s = trim(str_replace([' ', '%'], '', input_str($s))); + if ($s === '') { + return null; + } + $s = str_replace(',', '.', $s); + if (!preg_match('/^-?\d+(\.\d+)?$/', $s)) { + return false; + } + return round(((float)$s) / 100, 8); +} + +function fmt_setting(array $row): string +{ + $v = (float)$row['value']; + switch ($row['unit']) { + case 'pct': + return fmt_pct($v); + case 'idr': + return fmt_idr($v); + default: + return rtrim(rtrim(number_format($v, 2, ',', ''), '0'), ','); + } +} + +function fmt_datetime(?string $s): string +{ + if (!$s) { + return '—'; + } + $months = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des']; + $t = strtotime($s); + return date('j', $t) . ' ' . $months[(int)date('n', $t)] . ' ' . date('Y, H.i', $t); +} + +// ---- Icons (Lucide, ISC licence) -------------------------------------------- + +function icon(string $name, string $class = 'icon'): string +{ + static $paths = [ + 'printer' => '', + 'copy' => '', + 'reset' => '', + 'info' => '', + 'alert' => '', + 'check' => '', + 'lock' => '', + 'logout' => '', + 'plus' => '', + 'trash' => '', + 'users' => '', + 'history' => '', + 'sliders' => '', + 'wallet' => '', + 'grid' => '', + 'pie' => '', + 'calc' => '', + 'sun' => '', + 'moon' => '', + 'chevron' => '', + 'save' => '', + 'key' => '', + 'external' => '', + 'menu' => '', + 'x' => '', + ]; + return ''; +} + +/** + * Official Bank Papua logo. 'auto' swaps the color logo for the white one in + * dark mode; 'white' is for navy surfaces; 'color' for paper/print. + * The images are decorative (alt=""); the surrounding link or heading carries the name. + */ +function bank_logo(string $variant = 'auto', string $class = ''): string +{ + $img = function (string $file, string $cls): string { + return ''; + }; + $color = $img('logo-bank-papua.png', 'logo-color'); + $white = $img('logo-bank-papua-white.png', 'logo-white'); + $html = $variant === 'white' ? $white : ($variant === 'color' ? $color : $color . $white); + return '' . $html . ''; +} + +/** Inline script that applies the saved theme before first paint. */ +function theme_boot_script(): string +{ + return '"; +} diff --git a/includes/params.php b/includes/params.php new file mode 100644 index 0000000..051b1ba --- /dev/null +++ b/includes/params.php @@ -0,0 +1,113 @@ + 'Giro Umum', 'simanja' => 'Tabungan SIMANJA', 'simpeda' => 'Tabungan SIMPEDA']; + +const SERUBI_GROUPS = [ + 'dana' => ['Struktur dana', 'Bagaimana dana nasabah dibagi sebelum menghasilkan pendapatan.'], + 'bunga' => ['Suku bunga pendapatan', 'Rate yang menghasilkan pendapatan bank dari dana kelolaan.'], + 'biaya' => ['Biaya', 'Biaya yang mengurangi spread sebelum dibagi ke nasabah.'], + 'pajak' => ['Pajak', 'Tarif pajak atas bunga dan cashback.'], + 'konvensi' => ['Konvensi perhitungan', 'Hari bunga, pinalti, dan batas minimal.'], +]; + +/** Raw settings rows keyed by skey. */ +function settings_rows(): array +{ + $rows = db()->query('SELECT * FROM settings ORDER BY sort')->fetchAll(); + return array_column($rows, null, 'skey'); +} + +function to_num($v): ?float +{ + return $v === null ? null : (float)$v; +} + +const PARAMS_CACHE_TTL = 300; // seconds; the cache is also cleared whenever an admin saves + +function params_cache_file(): string +{ + $dir = (string)(app_config()['app']['cache_dir'] ?? ''); + if ($dir === '') { + $dir = sys_get_temp_dir() . '/serubi-' . substr(md5(__DIR__), 0, 12); + } + return rtrim($dir, '/\\') . '/params.json'; +} + +/** Parameters in the shape assets/js/engine.js expects (cached to spare the database). */ +function load_params(): array +{ + $file = params_cache_file(); + if (is_file($file) && time() - (int)@filemtime($file) < PARAMS_CACHE_TTL) { + $cached = json_decode((string)@file_get_contents($file), true); + if (is_array($cached) && isset($cached['settings'])) { + return $cached; + } + } + $params = load_params_db(); + $dir = dirname($file); + if (is_dir($dir) || @mkdir($dir, 0700, true)) { + $tmp = $file . '.' . bin2hex(random_bytes(4)); + if (@file_put_contents($tmp, json_encode($params, JSON_PRESERVE_ZERO_FRACTION)) !== false) { + @rename($tmp, $file); + } + @unlink($tmp); + } + return $params; +} + +function params_cache_clear(): void +{ + @unlink(params_cache_file()); +} + +function load_params_db(): array +{ + $pdo = db(); + $settings = []; + $updated = null; + foreach (settings_rows() as $key => $row) { + $settings[$key] = (float)$row['value']; + $updated = max($updated ?? '', $row['updated_at']); + } + + $tenors = $pdo->query('SELECT months, cashback FROM tenors ORDER BY months')->fetchAll(); + + $productRates = array_fill_keys(array_keys(SERUBI_PRODUCTS), []); + foreach ($pdo->query('SELECT product, max_nominal, rate FROM product_rates ORDER BY product, sort, id') as $r) { + $productRates[$r['product']][] = ['max' => to_num($r['max_nominal']), 'rate' => (float)$r['rate']]; + } + + $bands = $pdo->query('SELECT id, max_nominal FROM deposito_bands ORDER BY sort, id')->fetchAll(); + $rates = []; + foreach ($pdo->query('SELECT band_id, tenor, rate FROM deposito_rates') as $r) { + $rates[$r['band_id']][$r['tenor']] = (float)$r['rate']; + } + $depositoRates = []; + foreach ($tenors as $t) { + $m = (int)$t['months']; + $depositoRates[$m] = array_map(fn($b) => [ + 'max' => to_num($b['max_nominal']), + 'rate' => $rates[$b['id']][$m] ?? 0.0, + ], $bands); + } + + $tiers = array_map(fn($r) => ['min' => (float)$r['min_nominal'], 'nasabah' => (float)$r['share_nasabah']], + $pdo->query('SELECT min_nominal, share_nasabah FROM cashback_tiers ORDER BY min_nominal')->fetchAll()); + + $last = $pdo->query("SELECT username, created_at FROM audit_log WHERE action LIKE 'param%' ORDER BY id DESC LIMIT 1")->fetch(); + + return [ + 'settings' => $settings, + 'tenors' => array_map(fn($t) => (int)$t['months'], $tenors), + 'cashbackTenors' => array_values(array_map(fn($t) => (int)$t['months'], array_filter($tenors, fn($t) => (int)$t['cashback'] === 1))), + 'productRates' => $productRates, + 'depositoBands' => array_map(fn($b) => to_num($b['max_nominal']), $bands), + 'depositoRates' => $depositoRates, + 'cashbackTiers' => $tiers, + 'updatedAt' => $last['created_at'] ?? $updated, + 'updatedBy' => $last['username'] ?? null, + ]; +} diff --git a/includes/security.php b/includes/security.php new file mode 100644 index 0000000..955c1f6 --- /dev/null +++ b/includes/security.php @@ -0,0 +1,153 @@ + false, + 'trusted_proxies' => [], + 'setup_key' => '', + ]; +} + +/** True when the client reached us over HTTPS (directly or via a trusted reverse proxy). */ +function is_https(): bool +{ + if (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off') { + return true; + } + if ((int)($_SERVER['SERVER_PORT'] ?? 0) === 443) { + return true; + } + $proxies = security_config()['trusted_proxies']; + if ($proxies && in_array($_SERVER['REMOTE_ADDR'] ?? '', $proxies, true)) { + return strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'; + } + return false; +} + +/** Client IP; X-Forwarded-For is honoured only when the request came from a trusted proxy. */ +function client_ip(): string +{ + $remote = (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); + $proxies = security_config()['trusted_proxies']; + if ($proxies && in_array($remote, $proxies, true) && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $chain = array_map('trim', explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR'])); + // right-most address that is not one of our proxies + foreach (array_reverse($chain) as $ip) { + if (filter_var($ip, FILTER_VALIDATE_IP) && !in_array($ip, $proxies, true)) { + return substr($ip, 0, 45); + } + } + } + return substr($remote, 0, 45); +} + +/** Per-request nonce for the few inline scripts (Content-Security-Policy). */ +function csp_nonce(): string +{ + static $nonce = null; + if ($nonce === null) { + $nonce = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '='); + } + return $nonce; +} + +function send_security_headers(bool $sensitive = false): void +{ + if (headers_sent()) { + return; + } + header_remove('X-Powered-By'); + $csp = [ + "default-src 'none'", + "script-src 'self' 'nonce-" . csp_nonce() . "'", + "style-src 'self'", + "img-src 'self'", + "font-src 'self'", + "connect-src 'self'", + "manifest-src 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "base-uri 'none'", + "object-src 'none'", + ]; + if (is_https()) { + $csp[] = 'upgrade-insecure-requests'; + header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); + } + header('Content-Security-Policy: ' . implode('; ', $csp)); + header('X-Frame-Options: DENY'); + header('X-Content-Type-Options: nosniff'); + header('Referrer-Policy: same-origin'); + header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()'); + header('Cross-Origin-Opener-Policy: same-origin'); + header('Cross-Origin-Resource-Policy: same-origin'); + header('X-Permitted-Cross-Domain-Policies: none'); + // Pages always revalidate so they reference the newest versioned assets; + // admin pages are never stored at all. + header($sensitive ? 'Cache-Control: no-store, max-age=0' : 'Cache-Control: no-cache, must-revalidate'); + if ($sensitive) { + header('Pragma: no-cache'); + } +} + +function security_bootstrap(): void +{ + static $done = false; + if ($done || PHP_SAPI === 'cli') { + return; + } + $done = true; + + // Never leak errors or stack traces to the client. + ini_set('display_errors', '0'); + ini_set('display_startup_errors', '0'); + ini_set('log_errors', '1'); + ini_set('expose_php', '0'); + error_reporting(E_ALL); + set_exception_handler(function (Throwable $e): void { + error_log('[serubi] ' . get_class($e) . ': ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: text/html; charset=utf-8'); + header('Cache-Control: no-store'); + } + echo 'Terjadi kesalahan

    Terjadi kesalahan pada server. Silakan coba lagi beberapa saat lagi.

    '; + }); + + // Hardened session settings (before any session_start()). + ini_set('session.use_strict_mode', '1'); + ini_set('session.use_only_cookies', '1'); + ini_set('session.use_trans_sid', '0'); + ini_set('session.cookie_httponly', '1'); + ini_set('session.sid_length', '48'); + ini_set('session.sid_bits_per_character', '5'); + ini_set('session.gc_maxlifetime', '28800'); + + // Only GET, HEAD and POST are meaningful here (blocks TRACE/PUT/DELETE/OPTIONS...). + $method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); + if (!in_array($method, ['GET', 'HEAD', 'POST'], true)) { + header('Allow: GET, HEAD, POST'); + http_response_code(405); + exit; + } + + // Optional HTTP -> HTTPS redirect for production. + if (security_config()['force_https'] && !is_https()) { + $host = preg_replace('/[^A-Za-z0-9.\-:\[\]]/', '', (string)($_SERVER['HTTP_HOST'] ?? '')); + $uri = (string)($_SERVER['REQUEST_URI'] ?? '/'); + if ($host !== '' && $uri !== '' && $uri[0] === '/') { + header('Location: https://' . $host . $uri, true, 301); + exit; + } + } +} diff --git a/includes/totp.php b/includes/totp.php new file mode 100644 index 0000000..a5ad7f7 --- /dev/null +++ b/includes/totp.php @@ -0,0 +1,123 @@ + $lastStep) && hash_equals(totp_code($secret, $s), $code)) { + return $s; + } + } + return null; +} + +function totp_uri(string $secret, string $username): string +{ + $label = rawurlencode('SERUBI Bank Papua:' . $username); + return "otpauth://totp/$label?secret=$secret&issuer=" . rawurlencode('SERUBI Bank Papua') . '&period=' . TOTP_PERIOD . '&digits=' . TOTP_DIGITS; +} + +function secret_encrypt(string $plain): string +{ + $iv = random_bytes(12); + $tag = ''; + $ct = openssl_encrypt($plain, 'aes-256-gcm', app_key(), OPENSSL_RAW_DATA, $iv, $tag); + return base64_encode($iv . $tag . $ct); +} + +function secret_decrypt(string $stored): string +{ + $raw = (string)base64_decode($stored, true); + $plain = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', app_key(), OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, 12, 16)); + if ($plain === false) { + throw new RuntimeException('OTP secret tidak dapat dibuka (app_key berubah?).'); + } + return $plain; +} + +function recovery_hash(string $code): string +{ + return hash_hmac('sha256', strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code)), app_key()); +} + +/** Fresh one-time codes like "K7QD-M2XP": [plain codes to show once, hashes to store]. */ +function recovery_codes_new(): array +{ + $plain = []; + for ($i = 0; $i < RECOVERY_CODE_COUNT; $i++) { + $c = substr(base32_encode(random_bytes(5)), 0, 8); + $plain[] = substr($c, 0, 4) . '-' . substr($c, 4); + } + return [$plain, array_map('recovery_hash', $plain)]; +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..46ae6d1 --- /dev/null +++ b/index.php @@ -0,0 +1,214 @@ +getMessage(); +} +$tenors = $params['tenors'] ?? [1, 3, 6, 12, 24]; +$cbTenors = $params['cashbackTenors'] ?? []; +?> + + + + + + + +Kalkulator Cashback SERUBI · Bank Papua + + + + + + + + + + + +
    + + + SERUBI + +
    + + Admin +
    +
    + + +
    + +
    + +
    +
    +
    +

    Kalkulator Cashback

    +

    Tabungan/Giro + cashback dibandingkan deposito

    +
    + +
    +
    + Cashback neto + +
    + Rp0 +
    +
    Bruto
    Rp0
    +
    Setara
    0,00% p.a.
    +
    Per bulan
    Rp0
    +
    + +
    + +
    +
    + +
    + + +
    +

    Cashback berlaku mulai Rp50.000.000

    + +
    + '50 jt', 100e6 => '100 jt', 250e6 => '250 jt', 500e6 => '500 jt', 1e9 => '1 M', 2e9 => '2 M'] as $v => $lbl): ?> + + +
    +
    + +
    + Jangka waktu +
    + + + +
    +
    + +
    + Jenis nasabah +
    + + +
    +
    + +
    + + + +
    +

    + +
    +
    + +
    + +
    +
    + +

    Simulasi Cashback SERUBI

    +

    +
    +
    Nominal
    +
    Jangka waktu
    +
    Jenis nasabah
    +
    +
    + +
    +
    +
    Cashback bruto
    +
    Pajak cashback
    +
    Cashback neto
    +
    Setara per tahun
    +
    +
    + +
    +

    Total diterima selama

    +
      +

      Total rate = rate produk + cashback neto disetahunkan.

      +
      + + +
      +

      Rincian perhitungan Hanya admin

      +
      +
      + + +
      +

      Bila dicairkan sebelum jatuh tempo

      +
      +

      Nasabah mengembalikan cashback neto beserta pajaknya, ditambah pinalti dari cashback neto.

      +
      + +
      +

      Parameter berlaku per .

      + +

      Hasil simulasi bersifat estimasi, bukan dokumen resmi atau penawaran yang mengikat, dan dapat berubah sesuai ketentuan Bank Papua.

      +
      +
      +
      +
      + + + + engine_client_model($params), 'calcUrl' => base_url('calc.php'), 'swUrl' => base_url('sw.js')]; +if ($admin) { + $client['full'] = $params; +} +?> + + + + + + + + + diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..cbf8f38 --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "Kalkulator Cashback SERUBI · Bank Papua", + "short_name": "SERUBI", + "description": "Simulasi cashback SERUBI untuk staf Bank Papua.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#0b2e66", + "theme_color": "#0b2e66", + "lang": "id", + "icons": [ + { "src": "assets/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } + ] +} diff --git a/php.ini b/php.ini new file mode 100644 index 0000000..6c73819 --- /dev/null +++ b/php.ini @@ -0,0 +1,7 @@ +; cPanel-generated php ini directives, do not edit +; Manual editing of this file may result in unexpected behavior. +; To make changes to this file, use the cPanel MultiPHP INI Editor (Home >> Software >> MultiPHP INI Editor) +; For more information, read our documentation (https://go.cpanel.net/EA4ModifyINI) + +error_log = "/home/k5551916/logs/php.error.log" +log_errors = On diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..4ab223c --- /dev/null +++ b/sw.js @@ -0,0 +1,75 @@ +/* + * Offline copy of the public calculator (field staff often have weak signal). + * - The calculator page: network first (fresh parameters), stored copy when + * offline or slow. Responses marked no-store (admin views) are never kept. + * - Versioned assets (assets/...?v=): stored copy first. + * - calc.php, admin/ and everything else: network only, never stored. + */ +'use strict'; +var CACHE = 'serubi-v1'; +var PAGE_TIMEOUT_MS = 4000; +var SCOPE = self.registration.scope; // e.g. https://host/ or http://localhost/calcserubi-prod/ +var PAGE_KEY = SCOPE; // one stored copy of the calculator, whatever the query string + +self.addEventListener('install', function () { self.skipWaiting(); }); + +self.addEventListener('activate', function (event) { + event.waitUntil(caches.keys().then(function (keys) { + return Promise.all(keys.filter(function (k) { return k !== CACHE; }).map(function (k) { return caches.delete(k); })); + }).then(function () { return self.clients.claim(); })); +}); + +function isCalculatorPage(url) { + var rel = url.href.slice(SCOPE.length).split('?')[0]; + return rel === '' || rel === 'index.php'; +} + +function storable(res) { + return res && res.ok && res.type === 'basic' && !/no-store/i.test(res.headers.get('Cache-Control') || ''); +} + +function pageNetworkFirst(request) { + return caches.open(CACHE).then(function (cache) { + var network = fetch(request).then(function (res) { + if (storable(res)) cache.put(PAGE_KEY, res.clone()); + return res; + }); + network.catch(function () { /* handled below */ }); + var timeout = new Promise(function (resolve) { setTimeout(resolve, PAGE_TIMEOUT_MS); }); + // slow network: answer from the stored copy after the timeout, if there is one + return Promise.race([network, timeout.then(function () { return cache.match(PAGE_KEY).then(function (c) { return c || network; }); })]) + .catch(function () { return cache.match(PAGE_KEY).then(function (c) { if (c) return c; throw new Error('offline'); }); }); + }); +} + +function assetCacheFirst(request) { + return caches.open(CACHE).then(function (cache) { + return cache.match(request).then(function (hit) { + if (hit) return hit; + return fetch(request).then(function (res) { + if (storable(res)) { + // keep only the newest version (?v=) of each asset + var path = new URL(request.url).pathname; + cache.keys().then(function (keys) { + keys.forEach(function (k) { if (new URL(k.url).pathname === path && k.url !== request.url) cache.delete(k); }); + }); + cache.put(request, res.clone()); + } + return res; + }); + }); + }); +} + +self.addEventListener('fetch', function (event) { + var req = event.request; + if (req.method !== 'GET') return; + var url = new URL(req.url); + if (url.origin !== self.location.origin || req.url.indexOf(SCOPE) !== 0) return; + if (req.mode === 'navigate' && isCalculatorPage(url)) { + event.respondWith(pageNetworkFirst(req)); + } else if (url.pathname.indexOf('/assets/') !== -1) { + event.respondWith(assetCacheFirst(req)); + } + // anything else (calc.php, admin/, ...) goes straight to the network +}); diff --git a/tests/excel-fixtures.json b/tests/excel-fixtures.json new file mode 100644 index 0000000..cfa6b28 --- /dev/null +++ b/tests/excel-fixtures.json @@ -0,0 +1,5431 @@ +{ + "source": "CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx", + "generatedAt": "2026-10-07T19:08:25", + "cases": [ + { + "nominal": 100000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 0, + "simpedaNet": 0, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0, + "rateSimpeda": 0, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 638.05684931506846, + "puabIncome": 37.30684931506849, + "creditIncome": 600.75, + "spreadSimanja": 436.40788657534245, + "spreadSimpeda": 436.40788657534245, + "spreadGiro": 436.40788657534245, + "depositoGross": 0 + }, + { + "nominal": 500000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 246.4, + "simpedaNet": 164, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 3190.2842465753424, + "puabIncome": 186.53424657534245, + "creditIncome": 3003.75, + "spreadSimanja": 1874.0394328767122, + "spreadSimpeda": 1977.0394328767122, + "spreadGiro": 2182.0394328767125, + "depositoGross": 0 + }, + { + "nominal": 999999, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 492.8, + "simpedaNet": 328, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 6380.5621125821917, + "puabIncome": 373.0681200821918, + "creditIncome": 6007.4939925, + "spreadSimanja": 3748.0745016745586, + "spreadSimpeda": 3954.0745016745586, + "spreadGiro": 4364.0745016745586, + "depositoGross": 0 + }, + { + "nominal": 1000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 492.8, + "simpedaNet": 328, + "depositoNet": 1972, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 1972, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.03, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 6380.5684931506848, + "puabIncome": 373.0684931506849, + "creditIncome": 6007.5, + "spreadSimanja": 3748.0788657534245, + "spreadSimpeda": 3954.0788657534245, + "spreadGiro": 4364.0788657534249, + "depositoGross": 2465 + }, + { + "nominal": 10000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 4931.2, + "simpedaNet": 3287.2, + "depositoNet": 19725.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 19725.6, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.03, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 63805.684931506847, + "puabIncome": 3730.6849315068489, + "creditIncome": 60075, + "spreadSimanja": 37476.788657534242, + "spreadSimpeda": 39531.788657534242, + "spreadGiro": 43640.788657534242, + "depositoGross": 24657 + }, + { + "nominal": 49999999, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 24656.8, + "simpedaNet": 16437.6, + "depositoNet": 98629.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 98629.6, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.03, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 319028.41827696573, + "puabIncome": 18653.424284465753, + "creditIncome": 300374.9939925, + "spreadSimanja": 187382.93892359233, + "spreadSimpeda": 197656.93892359233, + "spreadGiro": 218203.93892359233, + "depositoGross": 123287 + }, + { + "nominal": 50000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 24656.8, + "simpedaNet": 16437.6, + "depositoNet": 98629.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 98629.6, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.03, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 319028.42465753423, + "puabIncome": 18653.424657534244, + "creditIncome": 300375, + "spreadSimanja": 187382.94328767122, + "spreadSimpeda": 197656.94328767119, + "spreadGiro": 218203.94328767119, + "depositoGross": 123287 + }, + { + "nominal": 55000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 18081.6, + "simanjaNet": 27123.2, + "simpedaNet": 18081.6, + "depositoNet": 108492.8, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 108492.8, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.03, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 350931.26712328766, + "puabIncome": 20518.767123287667, + "creditIncome": 330412.5, + "spreadSimanja": 206120.33761643834, + "spreadSimpeda": 217422.33761643834, + "spreadGiro": 217422.33761643834, + "depositoGross": 135616 + }, + { + "nominal": 99999999, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 32876, + "simanjaNet": 49314.4, + "simpedaNet": 32876, + "depositoNet": 197260, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 197260, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.03, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 638056.8429345, + "puabIncome": 37306.848942, + "creditIncome": 600749.9939925, + "spreadSimanja": 374764.88221126358, + "spreadSimpeda": 395312.88221126358, + "spreadGiro": 395312.88221126358, + "depositoGross": 246575 + }, + { + "nominal": 100000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 32876, + "simanjaNet": 49314.4, + "simpedaNet": 32876, + "depositoNet": 197260, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 197260, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.03, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 638056.84931506845, + "puabIncome": 37306.849315068488, + "creditIncome": 600750, + "spreadSimanja": 374764.88657534245, + "spreadSimpeda": 395312.88657534239, + "spreadGiro": 395312.88657534239, + "depositoGross": 246575 + }, + { + "nominal": 100000001, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 32876, + "simanjaNet": 65752.8, + "simpedaNet": 49314.4, + "depositoNet": 200547.2, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 200547.2, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.0305, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 638056.855695637, + "puabIncome": 37306.849688136979, + "creditIncome": 600750.0060075, + "spreadSimanja": 354216.89093942125, + "spreadSimpeda": 374764.89093942125, + "spreadGiro": 395312.89093942125, + "depositoGross": 250684 + }, + { + "nominal": 150000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 49314.4, + "simanjaNet": 98629.6, + "simpedaNet": 73972, + "depositoNet": 300821.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 300821.6, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.0305, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 957085.27397260279, + "puabIncome": 55960.273972602736, + "creditIncome": 901125, + "spreadSimanja": 531324.8298630137, + "spreadSimpeda": 562146.8298630137, + "spreadGiro": 592968.8298630137, + "depositoGross": 376027 + }, + { + "nominal": 250000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 82191.2, + "simanjaNet": 164383.2, + "simpedaNet": 123287.2, + "depositoNet": 501369.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 501369.6, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.0305, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 1595142.1232876712, + "puabIncome": 93267.123287671231, + "creditIncome": 1501875, + "spreadSimanja": 885540.7164383562, + "spreadSimpeda": 936910.7164383562, + "spreadGiro": 988280.7164383562, + "depositoGross": 626712 + }, + { + "nominal": 250000001, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 82191.2, + "simanjaNet": 164383.2, + "simpedaNet": 123287.2, + "depositoNet": 509588.8, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 509588.8, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.031, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 1595142.1296682397, + "puabIncome": 93267.12366073973, + "creditIncome": 1501875.0060075, + "spreadSimanja": 885540.720802435, + "spreadSimpeda": 936910.720802435, + "spreadGiro": 988280.720802435, + "depositoGross": 636986 + }, + { + "nominal": 300000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 98629.6, + "simanjaNet": 197260, + "simpedaNet": 147944.8, + "depositoNet": 611506.4, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 611506.4, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.031, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 1914170.5479452056, + "puabIncome": 111920.54794520547, + "creditIncome": 1802250, + "spreadSimanja": 1062648.6597260274, + "spreadSimpeda": 1124292.6597260274, + "spreadGiro": 1185936.6597260274, + "depositoGross": 764383 + }, + { + "nominal": 499999999, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 164383.2, + "simanjaNet": 328766.4, + "simpedaNet": 246575.2, + "depositoNet": 1019177.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 1019177.6, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.031, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 3190284.2401947738, + "puabIncome": 186534.24620227396, + "creditIncome": 3003749.9939925, + "spreadSimanja": 1771081.4285126333, + "spreadSimpeda": 1873820.4285126333, + "spreadGiro": 1976560.4285126333, + "depositoGross": 1273972 + }, + { + "nominal": 500000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 164383.2, + "simanjaNet": 328766.4, + "simpedaNet": 246575.2, + "depositoNet": 1019177.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 1019177.6, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.031, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 3190284.2465753425, + "puabIncome": 186534.24657534246, + "creditIncome": 3003750, + "spreadSimanja": 1771081.4328767124, + "spreadSimpeda": 1873820.4328767124, + "spreadGiro": 1976560.4328767124, + "depositoGross": 1273972 + }, + { + "nominal": 500000001, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 164383.2, + "simanjaNet": 657533.6, + "simpedaNet": 493150.4, + "depositoNet": 1068492.8, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 1068492.8, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.0325, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 3190284.2529559107, + "puabIncome": 186534.24694841093, + "creditIncome": 3003750.0060075, + "spreadSimanja": 1360122.437240791, + "spreadSimpeda": 1565601.437240791, + "spreadGiro": 1976560.437240791, + "depositoGross": 1335616 + }, + { + "nominal": 750000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 246575.2, + "simanjaNet": 986300.8, + "simpedaNet": 739725.6, + "depositoNet": 1602739.2, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 1602739.2, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.0325, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 4785426.3698630137, + "puabIncome": 279801.36986301368, + "creditIncome": 4505625, + "spreadSimanja": 2040183.1493150685, + "spreadSimpeda": 2348402.1493150685, + "spreadGiro": 2964840.1493150685, + "depositoGross": 2003424 + }, + { + "nominal": 999999999, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 328766.4, + "simanjaNet": 1315068, + "simpedaNet": 986300.8, + "depositoNet": 2136985.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 2136985.6, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.0325, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 6380568.4867701167, + "puabIncome": 373068.49277761643, + "creditIncome": 6007499.9939925, + "spreadSimanja": 2720243.861389346, + "spreadSimpeda": 3131202.861389346, + "spreadGiro": 3953120.861389346, + "depositoGross": 2671232 + }, + { + "nominal": 1000000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 328766.4, + "simanjaNet": 1315068, + "simpedaNet": 986300.8, + "depositoNet": 2136985.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 2136985.6, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.0325, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 6380568.493150685, + "puabIncome": 373068.49315068492, + "creditIncome": 6007500, + "spreadSimanja": 2720243.8657534248, + "spreadSimpeda": 3131202.8657534248, + "spreadGiro": 3953120.8657534248, + "depositoGross": 2671232 + }, + { + "nominal": 1000000001, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 328766.4, + "simanjaNet": 1643835.2, + "simpedaNet": 1315068, + "depositoNet": 2235616, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 2235616, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 6380568.4995312532, + "puabIncome": 373068.49352375336, + "creditIncome": 6007500.0060075, + "spreadSimanja": 2309284.8701175032, + "spreadSimpeda": 2720243.8701175032, + "spreadGiro": 3953120.8701175032, + "depositoGross": 2794520 + }, + { + "nominal": 1500000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 493150.4, + "simanjaNet": 2465752.8, + "simpedaNet": 1972602.4, + "depositoNet": 3353424, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 3353424, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 9570852.7397260275, + "puabIncome": 559602.73972602736, + "creditIncome": 9011250, + "spreadSimanja": 3463927.298630137, + "spreadSimpeda": 4080365.298630137, + "spreadGiro": 5929680.298630137, + "depositoGross": 4191780 + }, + { + "nominal": 1999999999, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 657533.6, + "simanjaNet": 3287671.2, + "simpedaNet": 2630136.8, + "depositoNet": 4471232.8, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 4471232.8, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 12761136.9799208, + "puabIncome": 746136.98592830135, + "creditIncome": 12014999.9939925, + "spreadSimanja": 4618568.72714277, + "spreadSimpeda": 5440486.72714277, + "spreadGiro": 7906240.72714277, + "depositoGross": 5589041 + }, + { + "nominal": 2000000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 657533.6, + "simanjaNet": 3287671.2, + "simpedaNet": 2630136.8, + "depositoNet": 4471232.8, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 4471232.8, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 12761136.98630137, + "puabIncome": 746136.98630136985, + "creditIncome": 12015000, + "spreadSimanja": 4618568.73150685, + "spreadSimpeda": 5440486.73150685, + "spreadGiro": 7906240.73150685, + "depositoGross": 5589041 + }, + { + "nominal": 2000000001, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 657533.6, + "simanjaNet": 3287671.2, + "simpedaNet": 2630136.8, + "depositoNet": 4471232.8, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 4471232.8, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 12761136.992681934, + "puabIncome": 746136.98667443811, + "creditIncome": 12015000.006007496, + "spreadSimanja": 4618568.7378709232, + "spreadSimpeda": 5440486.7378709232, + "spreadGiro": 7906240.7378709232, + "depositoGross": 5589041 + }, + { + "nominal": 5000000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 1643835.2, + "simanjaNet": 8219177.6, + "simpedaNet": 6575342.4, + "depositoNet": 11178081.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 11178081.6, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 31902842.465753425, + "puabIncome": 1865342.4657534244, + "creditIncome": 30037500, + "spreadSimanja": 17546422.328767121, + "spreadSimpeda": 19601216.328767125, + "spreadGiro": 25765600.328767125, + "depositoGross": 13972602 + }, + { + "nominal": 10000000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 3287671.2, + "simanjaNet": 16438356, + "simpedaNet": 13150684.8, + "depositoNet": 22356164, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 22356164, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 63805684.93150685, + "puabIncome": 3730684.9315068489, + "creditIncome": 60075000, + "spreadSimanja": 39092843.657534242, + "spreadSimpeda": 43202432.657534249, + "spreadGiro": 55531199.657534249, + "depositoGross": 27945205 + }, + { + "nominal": 50000000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 16438356, + "simanjaNet": 82191780.8, + "simpedaNet": 65753424, + "depositoNet": 111780821.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 111780821.6, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 319028424.65753424, + "puabIncome": 18653424.657534245, + "creditIncome": 300375000, + "spreadSimanja": 211464217.28767121, + "spreadSimpeda": 232012163.28767121, + "spreadGiro": 293655998.28767121, + "depositoGross": 139726027 + }, + { + "nominal": 50000000001, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 24657533.6, + "simanjaNet": 82191780.8, + "simpedaNet": 65753424, + "depositoNet": 111780821.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 111780821.6, + "rateGiro": 0.0075, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 319028424.6639148, + "puabIncome": 18653424.657907311, + "creditIncome": 300375000.00600749, + "spreadSimanja": 211464217.29403532, + "spreadSimpeda": 232012163.29403532, + "spreadGiro": 283382026.29403532, + "depositoGross": 139726027 + }, + { + "nominal": 100000000000, + "tenor": 1, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 49315068, + "simanjaNet": 164383561.6, + "simpedaNet": 131506848.8, + "depositoNet": 223561643.2, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 223561643.2, + "rateGiro": 0.0075, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 638056849.31506848, + "puabIncome": 37306849.315068491, + "creditIncome": 600750000, + "spreadSimanja": 426928434.57534242, + "spreadSimpeda": 468024325.57534242, + "spreadGiro": 570764051.57534242, + "depositoGross": 279452054 + }, + { + "nominal": 100000, + "tenor": 3, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 0, + "simpedaNet": 0, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0, + "rateSimpeda": 0, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 1914.1705479452055, + "puabIncome": 111.92054794520547, + "creditIncome": 1802.25, + "spreadSimanja": 1709.2236597260273, + "spreadSimpeda": 1709.2236597260273, + "spreadGiro": 1709.2236597260273, + "depositoGross": 0 + }, + { + "nominal": 500000, + "tenor": 3, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 739.2, + "simpedaNet": 492.8, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 9570.8527397260277, + "puabIncome": 559.60273972602738, + "creditIncome": 9011.25, + "spreadSimanja": 7622.1182986301374, + "spreadSimpeda": 7930.1182986301374, + "spreadGiro": 8546.1182986301374, + "depositoGross": 0 + }, + { + "nominal": 999999, + "tenor": 3, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 1479.2, + "simpedaNet": 985.6, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 19141.686337746574, + "puabIncome": 1119.2043602465753, + "creditIncome": 18022.4819775, + "spreadSimanja": 15243.219505023675, + "spreadSimpeda": 15860.219505023675, + "spreadGiro": 17092.219505023677, + "depositoGross": 0 + }, + { + "nominal": 1000000, + "tenor": 3, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 1479.2, + "simpedaNet": 985.6, + "depositoNet": 6114.4, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 6114.4, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.031, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 19141.705479452055, + "puabIncome": 1119.2054794520548, + "creditIncome": 18022.5, + "spreadSimanja": 15243.236597260275, + "spreadSimpeda": 15860.236597260275, + "spreadGiro": 17092.236597260275, + "depositoGross": 7643 + }, + { + "nominal": 10000000, + "tenor": 3, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 14794.4, + "simpedaNet": 9862.4, + "depositoNet": 61150.4, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 61150.4, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.031, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 191417.05479452055, + "puabIncome": 11192.054794520547, + "creditIncome": 180225, + "spreadSimanja": 152429.36597260274, + "spreadSimpeda": 158594.36597260274, + "spreadGiro": 170922.36597260274, + "depositoGross": 76438 + }, + { + "nominal": 49999999, + "tenor": 3, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 73972, + "simpedaNet": 49314.4, + "depositoNet": 305752.8, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 305752.8, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.031, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 957085.25483089732, + "puabIncome": 55960.272853397255, + "creditIncome": 901124.9819775, + "spreadSimanja": 762146.81277077715, + "spreadSimpeda": 792968.81277077715, + "spreadGiro": 854611.81277077715, + "depositoGross": 382191 + }, + { + "nominal": 50000000, + "tenor": 3, + "cashbackGross": 401621.24826484016, + "cashbackNet": 321296.99861187214, + "giroNet": 0, + "simanjaNet": 73972, + "simpedaNet": 49314.4, + "depositoNet": 305752.8, + "totalGiro": 321296.99861187214, + "totalSimanja": 395268.99861187214, + "totalSimpeda": 370611.39861187217, + "totalDeposito": 305752.8, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.026060756554074072, + "totalRateGiro": 0.026060756554074072, + "totalRateSimanja": 0.033560756554074075, + "totalRateSimpeda": 0.031060756554074073, + "rateDeposito": 0.031, + "penaltyRefund": 321296.99861187214, + "penaltyTax": 80324.249652968036, + "penaltyFee": 80324.249652968036, + "penaltyTotal": 481945.49791780818, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 957085.27397260279, + "puabIncome": 55960.273972602736, + "creditIncome": 901125, + "spreadSimanja": 762146.8298630137, + "spreadSimpeda": 792968.8298630137, + "spreadGiro": 854611.8298630137, + "depositoGross": 382191 + }, + { + "nominal": 55000000, + "tenor": 3, + "cashbackGross": 430481.83975799097, + "cashbackNet": 344385.47180639277, + "giroNet": 54246.4, + "simanjaNet": 81369.6, + "simpedaNet": 54246.4, + "depositoNet": 336328, + "totalGiro": 398631.8718063928, + "totalSimanja": 425755.07180639275, + "totalSimpeda": 398631.8718063928, + "totalDeposito": 336328, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.025394080244309766, + "totalRateGiro": 0.030394080244309767, + "totalRateSimanja": 0.032894080244309766, + "totalRateSimpeda": 0.030394080244309767, + "rateDeposito": 0.031, + "penaltyRefund": 344385.47180639277, + "penaltyTax": 86096.3679515982, + "penaltyFee": 86096.3679515982, + "penaltyTotal": 516578.20770958916, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 1052793.8013698631, + "puabIncome": 61556.301369863, + "creditIncome": 991237.5, + "spreadSimanja": 838361.01284931519, + "spreadSimpeda": 872265.01284931519, + "spreadGiro": 872265.01284931519, + "depositoGross": 420410 + }, + { + "nominal": 99999999, + "tenor": 3, + "cashbackGross": 782694.32131689554, + "cashbackNet": 626155.45705351641, + "giroNet": 98629.6, + "simanjaNet": 147944.8, + "simpedaNet": 98629.6, + "depositoNet": 611506.4, + "totalGiro": 724785.05705351639, + "totalSimanja": 774100.25705351634, + "totalSimpeda": 724785.05705351639, + "totalDeposito": 611506.4, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.025394082678888992, + "totalRateGiro": 0.030394082678888993, + "totalRateSimanja": 0.032894082678888992, + "totalRateSimpeda": 0.030394082678888993, + "rateDeposito": 0.031, + "penaltyRefund": 626155.45705351641, + "penaltyTax": 156538.8642633791, + "penaltyFee": 156538.8642633791, + "penaltyTotal": 939233.18558027467, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 1914170.5288035, + "puabIncome": 111920.54682599999, + "creditIncome": 1802249.9819775, + "spreadSimanja": 1524292.6426337911, + "spreadSimpeda": 1585936.6426337908, + "spreadGiro": 1585936.6426337908, + "depositoGross": 764383 + }, + { + "nominal": 100000000, + "tenor": 3, + "cashbackGross": 782694.3298630137, + "cashbackNet": 626155.463890411, + "giroNet": 98629.6, + "simanjaNet": 147944.8, + "simpedaNet": 98629.6, + "depositoNet": 611506.4, + "totalGiro": 724785.06389041094, + "totalSimanja": 774100.263890411, + "totalSimpeda": 724785.06389041094, + "totalDeposito": 611506.4, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.02539408270222222, + "totalRateGiro": 0.030394082702222221, + "totalRateSimanja": 0.032894082702222216, + "totalRateSimpeda": 0.030394082702222221, + "rateDeposito": 0.031, + "penaltyRefund": 626155.463890411, + "penaltyTax": 156538.86597260274, + "penaltyFee": 156538.86597260274, + "penaltyTotal": 939233.19583561644, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 1914170.5479452056, + "puabIncome": 111920.54794520547, + "creditIncome": 1802250, + "spreadSimanja": 1524292.6597260274, + "spreadSimpeda": 1585936.6597260274, + "spreadGiro": 1585936.6597260274, + "depositoGross": 764383 + }, + { + "nominal": 100000001, + "tenor": 3, + "cashbackGross": 762146.33840913186, + "cashbackNet": 609717.07072730549, + "giroNet": 98629.6, + "simanjaNet": 197260, + "simpedaNet": 147944.8, + "depositoNet": 621369.6, + "totalGiro": 708346.67072730546, + "totalSimanja": 806977.07072730549, + "totalSimpeda": 757661.87072730553, + "totalDeposito": 621369.6, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.02472741428777769, + "totalRateGiro": 0.029727414287777691, + "totalRateSimanja": 0.034727414287777689, + "totalRateSimpeda": 0.032227414287777686, + "rateDeposito": 0.0315, + "penaltyRefund": 609717.07072730549, + "penaltyTax": 152429.26768182637, + "penaltyFee": 152429.26768182637, + "penaltyTotal": 914575.60609095823, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 1914170.5670869108, + "puabIncome": 111920.54906441094, + "creditIncome": 1802250.0180225, + "spreadSimanja": 1462648.6768182637, + "spreadSimpeda": 1524292.6768182637, + "spreadGiro": 1585936.6768182637, + "depositoGross": 776712 + }, + { + "nominal": 150000000, + "tenor": 3, + "cashbackGross": 1143219.2447945205, + "cashbackNet": 914575.39583561639, + "giroNet": 147944.8, + "simanjaNet": 295890.4, + "simpedaNet": 221917.6, + "depositoNet": 932054.4, + "totalGiro": 1062520.1958356164, + "totalSimanja": 1210465.7958356165, + "totalSimpeda": 1136492.9958356165, + "totalDeposito": 932054.4, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.024727408850370367, + "totalRateGiro": 0.029727408850370368, + "totalRateSimanja": 0.034727408850370366, + "totalRateSimpeda": 0.03222740885037037, + "rateDeposito": 0.0315, + "penaltyRefund": 914575.39583561639, + "penaltyTax": 228643.84895890413, + "penaltyFee": 228643.8489589041, + "penaltyTotal": 1371863.0937534247, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 2871255.8219178081, + "puabIncome": 167880.82191780821, + "creditIncome": 2703375, + "spreadSimanja": 2193972.4895890411, + "spreadSimpeda": 2286438.4895890411, + "spreadGiro": 2378904.4895890411, + "depositoGross": 1165068 + }, + { + "nominal": 250000000, + "tenor": 3, + "cashbackGross": 1905365.4079908675, + "cashbackNet": 1524292.3263926939, + "giroNet": 246575.2, + "simanjaNet": 493150.4, + "simpedaNet": 369862.4, + "depositoNet": 1553424, + "totalGiro": 1770867.5263926939, + "totalSimanja": 2017442.7263926938, + "totalSimpeda": 1894154.7263926938, + "totalDeposito": 1553424, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.024727408850370367, + "totalRateGiro": 0.029727408850370368, + "totalRateSimanja": 0.034727408850370366, + "totalRateSimpeda": 0.03222740885037037, + "rateDeposito": 0.0315, + "penaltyRefund": 1524292.3263926939, + "penaltyTax": 381073.08159817354, + "penaltyFee": 381073.08159817348, + "penaltyTotal": 2286438.4895890411, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 4785426.3698630137, + "puabIncome": 279801.36986301368, + "creditIncome": 4505625, + "spreadSimanja": 3656621.1493150685, + "spreadSimpeda": 3810731.1493150685, + "spreadGiro": 3964840.1493150685, + "depositoGross": 1941780 + }, + { + "nominal": 250000001, + "tenor": 3, + "cashbackGross": 1714828.8748832874, + "cashbackNet": 1371863.0999066299, + "giroNet": 246575.2, + "simanjaNet": 493150.4, + "simpedaNet": 369862.4, + "depositoNet": 1578081.6, + "totalGiro": 1618438.2999066298, + "totalSimanja": 1865013.4999066298, + "totalSimpeda": 1741725.4999066298, + "totalDeposito": 1578081.6, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.022254667976133322, + "totalRateGiro": 0.027254667976133323, + "totalRateSimanja": 0.032254667976133321, + "totalRateSimpeda": 0.029754667976133322, + "rateDeposito": 0.032, + "penaltyRefund": 1371863.0999066299, + "penaltyTax": 342965.77497665747, + "penaltyFee": 342965.77497665747, + "penaltyTotal": 2057794.6498599448, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 4785426.3890047194, + "puabIncome": 279801.37098221917, + "creditIncome": 4505625.0180225, + "spreadSimanja": 3656621.1664073053, + "spreadSimpeda": 3810731.1664073053, + "spreadGiro": 3964840.1664073053, + "depositoGross": 1972602 + }, + { + "nominal": 300000000, + "tenor": 3, + "cashbackGross": 2057794.4906301368, + "cashbackNet": 1646235.5925041095, + "giroNet": 295890.4, + "simanjaNet": 591780.8, + "simpedaNet": 443835.2, + "depositoNet": 1893698.4, + "totalGiro": 1942125.9925041096, + "totalSimanja": 2238016.3925041095, + "totalSimpeda": 2090070.7925041094, + "totalDeposito": 1893698.4, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.022254666343111108, + "totalRateGiro": 0.027254666343111109, + "totalRateSimanja": 0.03225466634311111, + "totalRateSimpeda": 0.029754666343111108, + "rateDeposito": 0.032, + "penaltyRefund": 1646235.5925041095, + "penaltyTax": 411558.89812602737, + "penaltyFee": 411558.89812602737, + "penaltyTotal": 2469353.3887561643, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 5742511.6438356163, + "puabIncome": 335761.64383561641, + "creditIncome": 5406750, + "spreadSimanja": 4387944.9791780822, + "spreadSimpeda": 4572876.9791780822, + "spreadGiro": 4757807.9791780822, + "depositoGross": 2367123 + }, + { + "nominal": 499999999, + "tenor": 3, + "cashbackGross": 3429657.5766920545, + "cashbackNet": 2743726.0613536434, + "giroNet": 493150.4, + "simanjaNet": 986300.8, + "simpedaNet": 739725.6, + "depositoNet": 3156164, + "totalGiro": 3236876.4613536433, + "totalSimanja": 3730026.8613536432, + "totalSimpeda": 3483451.6613536435, + "totalDeposito": 3156164, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.0222546669866, + "totalRateGiro": 0.0272546669866, + "totalRateSimanja": 0.0322546669866, + "totalRateSimpeda": 0.0297546669866, + "rateDeposito": 0.032, + "penaltyRefund": 2743726.0613536434, + "penaltyTax": 685931.515338411, + "penaltyFee": 685931.51533841086, + "penaltyTotal": 4115589.0920304656, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 9570852.7205843218, + "puabIncome": 559602.73860682186, + "creditIncome": 9011249.9819775, + "spreadSimanja": 7313242.2815379, + "spreadSimpeda": 7621461.2815379, + "spreadGiro": 7929680.2815379, + "depositoGross": 3945205 + }, + { + "nominal": 500000000, + "tenor": 3, + "cashbackGross": 3429657.5843835617, + "cashbackNet": 2743726.0675068493, + "giroNet": 493150.4, + "simanjaNet": 986300.8, + "simpedaNet": 739725.6, + "depositoNet": 3156164, + "totalGiro": 3236876.4675068492, + "totalSimanja": 3730026.8675068496, + "totalSimpeda": 3483451.6675068494, + "totalDeposito": 3156164, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.022254666992, + "totalRateGiro": 0.027254666992, + "totalRateSimanja": 0.032254666992, + "totalRateSimpeda": 0.029754666992, + "rateDeposito": 0.032, + "penaltyRefund": 2743726.0675068493, + "penaltyTax": 685931.51687671244, + "penaltyFee": 685931.51687671233, + "penaltyTotal": 4115589.1012602742, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 9570852.7397260275, + "puabIncome": 559602.73972602736, + "creditIncome": 9011250, + "spreadSimanja": 7313242.298630137, + "spreadSimpeda": 7621461.298630137, + "spreadGiro": 7929680.298630137, + "depositoGross": 3945205 + }, + { + "nominal": 500000001, + "tenor": 3, + "cashbackGross": 3106027.3420750685, + "cashbackNet": 2484821.873660055, + "giroNet": 493150.4, + "simanjaNet": 1972602.4, + "simpedaNet": 1479452, + "depositoNet": 3304108.8, + "totalGiro": 2977972.2736600549, + "totalSimanja": 4457424.2736600544, + "totalSimpeda": 3964273.873660055, + "totalDeposito": 3304108.8, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.020154666268266667, + "totalRateGiro": 0.025154666268266668, + "totalRateSimanja": 0.040154666268266664, + "totalRateSimpeda": 0.035154666268266667, + "rateDeposito": 0.0335, + "penaltyRefund": 2484821.873660055, + "penaltyTax": 621205.46841501375, + "penaltyFee": 621205.46841501375, + "penaltyTotal": 3727232.810490082, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 9570852.7588677332, + "puabIncome": 559602.74084523274, + "creditIncome": 9011250.0180225, + "spreadSimanja": 6080365.3157223742, + "spreadSimpeda": 6696803.3157223742, + "spreadGiro": 7929680.3157223742, + "depositoGross": 4130136 + }, + { + "nominal": 750000000, + "tenor": 3, + "cashbackGross": 4659041.0015753424, + "cashbackNet": 3727232.8012602739, + "giroNet": 739725.6, + "simanjaNet": 2958904, + "simpedaNet": 2219177.6, + "depositoNet": 4956164, + "totalGiro": 4466958.4012602735, + "totalSimanja": 6686136.8012602739, + "totalSimpeda": 5946410.4012602735, + "totalDeposito": 4956164, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.020154666258666666, + "totalRateGiro": 0.025154666258666667, + "totalRateSimanja": 0.040154666258666663, + "totalRateSimpeda": 0.035154666258666666, + "rateDeposito": 0.0335, + "penaltyRefund": 3727232.8012602739, + "penaltyTax": 931808.20031506848, + "penaltyFee": 931808.20031506848, + "penaltyTotal": 5590849.2018904109, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 14356279.10958904, + "puabIncome": 839404.109589041, + "creditIncome": 13516875, + "spreadSimanja": 9120547.4479452036, + "spreadSimpeda": 10045205.447945204, + "spreadGiro": 11894520.447945204, + "depositoGross": 6195205 + }, + { + "nominal": 999999999, + "tenor": 3, + "cashbackGross": 6212054.6610756153, + "cashbackNet": 4969643.7288604919, + "giroNet": 986300.8, + "simanjaNet": 3945204.8, + "simpedaNet": 2958904, + "depositoNet": 6608218.4, + "totalGiro": 5955944.5288604917, + "totalSimanja": 8914848.52886049, + "totalSimpeda": 7928547.7288604919, + "totalDeposito": 6608218.4, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.020154666253866662, + "totalRateGiro": 0.025154666253866663, + "totalRateSimanja": 0.040154666253866662, + "totalRateSimpeda": 0.035154666253866665, + "rateDeposito": 0.0335, + "penaltyRefund": 4969643.7288604919, + "penaltyTax": 1242410.9322151232, + "penaltyFee": 1242410.932215123, + "penaltyTotal": 7454465.5932907388, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 19141705.460310347, + "puabIncome": 1119205.4783328492, + "creditIncome": 18022499.9819775, + "spreadSimanja": 12160730.580168035, + "spreadSimpeda": 13393606.580168035, + "spreadGiro": 15859360.580168035, + "depositoGross": 8260273 + }, + { + "nominal": 1000000000, + "tenor": 3, + "cashbackGross": 6212054.6687671235, + "cashbackNet": 4969643.7350136992, + "giroNet": 986300.8, + "simanjaNet": 3945204.8, + "simpedaNet": 2958904, + "depositoNet": 6608218.4, + "totalGiro": 5955944.535013699, + "totalSimanja": 8914848.535013698, + "totalSimpeda": 7928547.7350136992, + "totalDeposito": 6608218.4, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.02015466625866667, + "totalRateGiro": 0.025154666258666671, + "totalRateSimanja": 0.04015466625866667, + "totalRateSimpeda": 0.035154666258666672, + "rateDeposito": 0.0335, + "penaltyRefund": 4969643.7350136992, + "penaltyTax": 1242410.9337534248, + "penaltyFee": 1242410.9337534248, + "penaltyTotal": 7454465.60252055, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 19141705.479452055, + "puabIncome": 1119205.4794520547, + "creditIncome": 18022500, + "spreadSimanja": 12160730.597260274, + "spreadSimpeda": 13393606.597260274, + "spreadGiro": 15859360.597260274, + "depositoGross": 8260273 + }, + { + "nominal": 1000000001, + "tenor": 3, + "cashbackGross": 5842191.7264586287, + "cashbackNet": 4673753.3811669033, + "giroNet": 986300.8, + "simanjaNet": 4931506.4, + "simpedaNet": 3945204.8, + "depositoNet": 6904108.8, + "totalGiro": 5660054.1811669031, + "totalSimanja": 9605259.7811669037, + "totalSimpeda": 8618958.1811669022, + "totalDeposito": 6904108.8, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.018954666471333328, + "totalRateGiro": 0.023954666471333329, + "totalRateSimanja": 0.04395466647133333, + "totalRateSimpeda": 0.038954666471333332, + "rateDeposito": 0.035, + "penaltyRefund": 4673753.3811669033, + "penaltyTax": 1168438.3452917258, + "penaltyFee": 1168438.3452917258, + "penaltyTotal": 7010630.0717503559, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 19141705.498593759, + "puabIncome": 1119205.4805712602, + "creditIncome": 18022500.0180225, + "spreadSimanja": 10927853.614352509, + "spreadSimpeda": 12160730.614352509, + "spreadGiro": 15859360.614352509, + "depositoGross": 8630136 + }, + { + "nominal": 1500000000, + "tenor": 3, + "cashbackGross": 8763287.203150684, + "cashbackNet": 7010629.762520547, + "giroNet": 1479452, + "simanjaNet": 7397260, + "simpedaNet": 5917808, + "depositoNet": 10356164, + "totalGiro": 8490081.762520548, + "totalSimanja": 14407889.762520548, + "totalSimpeda": 12928437.762520548, + "totalDeposito": 10356164, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.018954665654222221, + "totalRateGiro": 0.023954665654222222, + "totalRateSimanja": 0.043954665654222222, + "totalRateSimpeda": 0.038954665654222217, + "rateDeposito": 0.035, + "penaltyRefund": 7010629.762520547, + "penaltyTax": 1752657.440630137, + "penaltyFee": 1752657.4406301368, + "penaltyTotal": 10515944.64378082, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 28712558.219178081, + "puabIncome": 1678808.219178082, + "creditIncome": 27033750, + "spreadSimanja": 16391779.895890409, + "spreadSimpeda": 18241094.895890407, + "spreadGiro": 23789039.895890407, + "depositoGross": 12945205 + }, + { + "nominal": 1999999999, + "tenor": 3, + "cashbackGross": 11684382.979842739, + "cashbackNet": 9347506.383874191, + "giroNet": 1972602.4, + "simanjaNet": 9863013.6, + "simpedaNet": 7890410.4, + "depositoNet": 13808218.4, + "totalGiro": 11320108.783874191, + "totalSimanja": 19210519.983874191, + "totalSimpeda": 17237916.783874191, + "totalDeposito": 13808218.4, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.018954665732333328, + "totalRateGiro": 0.023954665732333329, + "totalRateSimanja": 0.04395466573233333, + "totalRateSimpeda": 0.038954665732333332, + "rateDeposito": 0.035, + "penaltyRefund": 9347506.383874191, + "penaltyTax": 2336876.5959685477, + "penaltyFee": 2336876.5959685477, + "penaltyTotal": 14021259.575811287, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 38283410.939762406, + "puabIncome": 2238410.9577849042, + "creditIncome": 36044999.9819775, + "spreadSimanja": 21855706.177428313, + "spreadSimpeda": 24321460.177428313, + "spreadGiro": 31718720.177428313, + "depositoGross": 17260273 + }, + { + "nominal": 2000000000, + "tenor": 3, + "cashbackGross": 7789588.6583561646, + "cashbackNet": 6231670.9266849319, + "giroNet": 1972602.4, + "simanjaNet": 9863013.6, + "simpedaNet": 7890410.4, + "depositoNet": 13808218.4, + "totalGiro": 8204273.3266849313, + "totalSimanja": 16094684.526684932, + "totalSimpeda": 14122081.326684933, + "totalDeposito": 13808218.4, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.012636443823555556, + "totalRateGiro": 0.017636443823555555, + "totalRateSimanja": 0.037636443823555556, + "totalRateSimpeda": 0.032636443823555558, + "rateDeposito": 0.035, + "penaltyRefund": 6231670.9266849319, + "penaltyTax": 1557917.731671233, + "penaltyFee": 1557917.731671233, + "penaltyTotal": 9347506.3900273982, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 38283410.95890411, + "puabIncome": 2238410.9589041094, + "creditIncome": 36045000, + "spreadSimanja": 21855706.194520548, + "spreadSimpeda": 24321460.194520548, + "spreadGiro": 31718720.194520548, + "depositoGross": 17260273 + }, + { + "nominal": 2000000001, + "tenor": 3, + "cashbackGross": 7789588.6640838338, + "cashbackNet": 6231670.9312670669, + "giroNet": 1972602.4, + "simanjaNet": 9863013.6, + "simpedaNet": 7890410.4, + "depositoNet": 13808218.4, + "totalGiro": 8204273.3312670663, + "totalSimanja": 16094684.531267066, + "totalSimpeda": 14122081.331267066, + "totalDeposito": 13808218.4, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.012636443826528884, + "totalRateGiro": 0.017636443826528885, + "totalRateSimanja": 0.037636443826528886, + "totalRateSimpeda": 0.032636443826528888, + "rateDeposito": 0.035, + "penaltyRefund": 6231670.9312670669, + "penaltyTax": 1557917.7328167669, + "penaltyFee": 1557917.7328167667, + "penaltyTotal": 9347506.3969006, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 38283410.978045806, + "puabIncome": 2238410.9600233142, + "creditIncome": 36045000.018022493, + "spreadSimanja": 21855706.213612776, + "spreadSimpeda": 24321460.213612776, + "spreadGiro": 31718720.213612776, + "depositoGross": 17260273 + }, + { + "nominal": 5000000000, + "tenor": 3, + "cashbackGross": 21273971.495890412, + "cashbackNet": 17019177.19671233, + "giroNet": 4931506.4, + "simanjaNet": 24657533.6, + "simpedaNet": 19726027.2, + "depositoNet": 34520547.2, + "totalGiro": 21950683.596712328, + "totalSimanja": 41676710.796712331, + "totalSimpeda": 36745204.396712333, + "totalDeposito": 34520547.2, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.013804443726222223, + "totalRateGiro": 0.018804443726222222, + "totalRateSimanja": 0.038804443726222222, + "totalRateSimpeda": 0.033804443726222225, + "rateDeposito": 0.035, + "penaltyRefund": 17019177.19671233, + "penaltyTax": 4254794.2991780825, + "penaltyFee": 4254794.2991780825, + "penaltyTotal": 25528765.795068495, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 95708527.397260278, + "puabIncome": 5596027.3972602729, + "creditIncome": 90112500, + "spreadSimanja": 60639265.986301377, + "spreadSimpeda": 66803648.986301377, + "spreadGiro": 85296799.986301377, + "depositoGross": 43150684 + }, + { + "nominal": 10000000000, + "tenor": 3, + "cashbackGross": 43747942.791780829, + "cashbackNet": 34998354.233424664, + "giroNet": 9863013.6, + "simanjaNet": 49315068, + "simpedaNet": 39452054.4, + "depositoNet": 69041095.2, + "totalGiro": 44861367.833424665, + "totalSimanja": 84313422.233424664, + "totalSimpeda": 74450408.63342467, + "totalDeposito": 69041095.2, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014193776994666668, + "totalRateGiro": 0.019193776994666668, + "totalRateSimanja": 0.039193776994666668, + "totalRateSimpeda": 0.034193776994666671, + "rateDeposito": 0.035, + "penaltyRefund": 34998354.233424664, + "penaltyTax": 8749588.5583561659, + "penaltyFee": 8749588.5583561659, + "penaltyTotal": 52497531.350136995, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 191417054.79452056, + "puabIncome": 11192054.794520546, + "creditIncome": 180225000, + "spreadSimanja": 125278530.97260275, + "spreadSimpeda": 137607297.97260275, + "spreadGiro": 174593598.97260275, + "depositoGross": 86301369 + }, + { + "nominal": 50000000000, + "tenor": 3, + "cashbackGross": 223539713.45890412, + "cashbackNet": 178831770.76712328, + "giroNet": 49315068, + "simanjaNet": 246575342.4, + "simpedaNet": 197260273.6, + "depositoNet": 345205479.2, + "totalGiro": 228146838.76712328, + "totalSimanja": 425407113.16712332, + "totalSimpeda": 376092044.36712325, + "totalDeposito": 345205479.2, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014505243628888888, + "totalRateGiro": 0.019505243628888887, + "totalRateSimanja": 0.039505243628888888, + "totalRateSimpeda": 0.03450524362888889, + "rateDeposito": 0.035, + "penaltyRefund": 178831770.76712328, + "penaltyTax": 44707942.691780828, + "penaltyFee": 44707942.69178082, + "penaltyTotal": 268247656.15068495, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 957085273.97260273, + "puabIncome": 55960273.97260274, + "creditIncome": 901125000, + "spreadSimanja": 642392651.86301374, + "spreadSimpeda": 704036487.86301374, + "spreadGiro": 888967994.86301374, + "depositoGross": 431506849 + }, + { + "nominal": 50000000001, + "tenor": 3, + "cashbackGross": 220457521.66463181, + "cashbackNet": 176366017.33170545, + "giroNet": 73972602.4, + "simanjaNet": 246575342.4, + "simpedaNet": 197260273.6, + "depositoNet": 345205479.2, + "totalGiro": 250338619.73170546, + "totalSimanja": 422941359.73170543, + "totalSimpeda": 373626290.93170547, + "totalDeposito": 345205479.2, + "rateGiro": 0.0075, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014305243627730004, + "totalRateGiro": 0.021805243627730002, + "totalRateSimanja": 0.039305243627730004, + "totalRateSimpeda": 0.034305243627730006, + "rateDeposito": 0.035, + "penaltyRefund": 176366017.33170545, + "penaltyTax": 44091504.332926363, + "penaltyFee": 44091504.332926363, + "penaltyTotal": 264549025.99755818, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 957085273.99174452, + "puabIncome": 55960273.973721936, + "creditIncome": 901125000.01802254, + "spreadSimanja": 642392651.88210607, + "spreadSimpeda": 704036487.88210607, + "spreadGiro": 858146076.88210607, + "depositoGross": 431506849 + }, + { + "nominal": 100000000000, + "tenor": 3, + "cashbackGross": 442115043.31780821, + "cashbackNet": 353692034.65424657, + "giroNet": 147945204.8, + "simanjaNet": 493150684.8, + "simpedaNet": 394520547.2, + "depositoNet": 690410958.4, + "totalGiro": 501637239.45424658, + "totalSimanja": 846842719.45424652, + "totalSimpeda": 748212581.85424662, + "totalDeposito": 690410958.4, + "rateGiro": 0.0075, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014344176960977777, + "totalRateGiro": 0.021844176960977776, + "totalRateSimanja": 0.039344176960977778, + "totalRateSimpeda": 0.034344176960977774, + "rateDeposito": 0.035, + "penaltyRefund": 353692034.65424657, + "penaltyTax": 88423008.663561642, + "penaltyFee": 88423008.663561642, + "penaltyTotal": 530538051.98136985, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 1914170547.9452055, + "puabIncome": 111920547.94520548, + "creditIncome": 1802250000, + "spreadSimanja": 1288785303.7260275, + "spreadSimpeda": 1412072975.7260275, + "spreadGiro": 1720292153.7260275, + "depositoGross": 863013698 + }, + { + "nominal": 100000, + "tenor": 6, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 0, + "simpedaNet": 0, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0, + "rateSimpeda": 0, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 3828.341095890411, + "puabIncome": 223.84109589041094, + "creditIncome": 3604.5, + "spreadSimanja": 3618.4473194520547, + "spreadSimpeda": 3618.4473194520547, + "spreadGiro": 3618.4473194520547, + "depositoGross": 0 + }, + { + "nominal": 500000, + "tenor": 6, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 1479.2, + "simpedaNet": 985.6, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 19141.705479452055, + "puabIncome": 1119.2054794520548, + "creditIncome": 18022.5, + "spreadSimanja": 16243.236597260275, + "spreadSimpeda": 16860.236597260275, + "spreadGiro": 18092.236597260275, + "depositoGross": 0 + }, + { + "nominal": 999999, + "tenor": 6, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 2958.4, + "simpedaNet": 1972, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 38283.372675493149, + "puabIncome": 2238.4087204931507, + "creditIncome": 36044.963955, + "spreadSimanja": 32486.437010047353, + "spreadSimpeda": 33719.437010047353, + "spreadGiro": 36184.437010047353, + "depositoGross": 0 + }, + { + "nominal": 1000000, + "tenor": 6, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 2958.4, + "simpedaNet": 1972, + "depositoNet": 12624, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 12624, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.032, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 38283.410958904111, + "puabIncome": 2238.4109589041095, + "creditIncome": 36045, + "spreadSimanja": 32486.473194520549, + "spreadSimpeda": 33719.473194520549, + "spreadGiro": 36184.473194520549, + "depositoGross": 15780 + }, + { + "nominal": 10000000, + "tenor": 6, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 29588.8, + "simpedaNet": 19725.6, + "depositoNet": 126246.4, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 126246.4, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.032, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 382834.10958904109, + "puabIncome": 22384.109589041094, + "creditIncome": 360450, + "spreadSimanja": 324858.73194520548, + "spreadSimpeda": 337187.73194520548, + "spreadGiro": 361844.73194520548, + "depositoGross": 157808 + }, + { + "nominal": 49999999, + "tenor": 6, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 147944.8, + "simpedaNet": 98629.6, + "depositoNet": 631232.8, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 631232.8, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.032, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 1914170.5096617946, + "puabIncome": 111920.54570679451, + "creditIncome": 1802249.963955, + "spreadSimanja": 1624292.6235415544, + "spreadSimpeda": 1685936.6235415544, + "spreadGiro": 1809223.6235415544, + "depositoGross": 789041 + }, + { + "nominal": 50000000, + "tenor": 6, + "cashbackGross": 853242.16319634707, + "cashbackNet": 682593.73055707768, + "giroNet": 0, + "simanjaNet": 147944.8, + "simpedaNet": 98629.6, + "depositoNet": 631232.8, + "totalGiro": 682593.73055707768, + "totalSimanja": 830538.53055707761, + "totalSimpeda": 781223.33055707766, + "totalDeposito": 631232.8, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.027682967961481484, + "totalRateGiro": 0.027682967961481484, + "totalRateSimanja": 0.035182967961481487, + "totalRateSimpeda": 0.032682967961481485, + "rateDeposito": 0.032, + "penaltyRefund": 682593.73055707768, + "penaltyTax": 170648.43263926942, + "penaltyFee": 170648.43263926942, + "penaltyTotal": 1023890.5958356165, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 1914170.5479452056, + "puabIncome": 111920.54794520547, + "creditIncome": 1802250, + "spreadSimanja": 1624292.6597260274, + "spreadSimpeda": 1685936.6597260274, + "spreadGiro": 1809223.6597260274, + "depositoGross": 789041 + }, + { + "nominal": 55000000, + "tenor": 6, + "cashbackGross": 915963.67951598193, + "cashbackNet": 732770.94361278554, + "giroNet": 108492.8, + "simanjaNet": 162739.2, + "simpedaNet": 108492.8, + "depositoNet": 694356, + "totalGiro": 841263.74361278559, + "totalSimanja": 895510.1436127855, + "totalSimpeda": 841263.74361278559, + "totalDeposito": 694356, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.027016302466531989, + "totalRateGiro": 0.03201630246653199, + "totalRateSimanja": 0.034516302466531992, + "totalRateSimpeda": 0.03201630246653199, + "rateDeposito": 0.032, + "penaltyRefund": 732770.94361278554, + "penaltyTax": 183192.73590319639, + "penaltyFee": 183192.73590319639, + "penaltyTotal": 1099156.4154191783, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 2105587.6027397262, + "puabIncome": 123112.602739726, + "creditIncome": 1982475, + "spreadSimanja": 1786722.0256986304, + "spreadSimpeda": 1854530.0256986304, + "spreadGiro": 1854530.0256986304, + "depositoGross": 867945 + }, + { + "nominal": 99999999, + "tenor": 6, + "cashbackGross": 1665388.1416337909, + "cashbackNet": 1332310.5133070326, + "giroNet": 197260, + "simanjaNet": 295890.4, + "simpedaNet": 197260, + "depositoNet": 1262465.6, + "totalGiro": 1529570.5133070326, + "totalSimanja": 1628200.9133070325, + "totalSimpeda": 1529570.5133070326, + "totalDeposito": 1262465.6, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.027016296790000015, + "totalRateGiro": 0.032016296790000016, + "totalRateSimanja": 0.034516296790000012, + "totalRateSimpeda": 0.032016296790000016, + "rateDeposito": 0.032, + "penaltyRefund": 1332310.5133070326, + "penaltyTax": 333077.62832675822, + "penaltyFee": 333077.62832675816, + "penaltyTotal": 1998465.7699605492, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 3828341.057607, + "puabIncome": 223841.09365199998, + "creditIncome": 3604499.963955, + "spreadSimanja": 3248584.2832675818, + "spreadSimpeda": 3371872.2832675818, + "spreadGiro": 3371872.2832675818, + "depositoGross": 1578082 + }, + { + "nominal": 100000000, + "tenor": 6, + "cashbackGross": 1665388.1597260274, + "cashbackNet": 1332310.527780822, + "giroNet": 197260, + "simanjaNet": 295890.4, + "simpedaNet": 197260, + "depositoNet": 1262465.6, + "totalGiro": 1529570.527780822, + "totalSimanja": 1628200.9277808219, + "totalSimpeda": 1529570.527780822, + "totalDeposito": 1262465.6, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.027016296813333337, + "totalRateGiro": 0.032016296813333338, + "totalRateSimanja": 0.034516296813333333, + "totalRateSimpeda": 0.032016296813333338, + "rateDeposito": 0.032, + "penaltyRefund": 1332310.527780822, + "penaltyTax": 333077.6319452055, + "penaltyFee": 333077.6319452055, + "penaltyTotal": 1998465.7916712328, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 3828341.0958904112, + "puabIncome": 223841.09589041094, + "creditIncome": 3604500, + "spreadSimanja": 3248584.3194520548, + "spreadSimpeda": 3371872.3194520548, + "spreadGiro": 3371872.3194520548, + "depositoGross": 1578082 + }, + { + "nominal": 100000001, + "tenor": 6, + "cashbackGross": 1624292.3444849306, + "cashbackNet": 1299433.8755879444, + "giroNet": 197260, + "simanjaNet": 394520, + "simpedaNet": 295890.4, + "depositoNet": 1282191.2, + "totalGiro": 1496693.8755879444, + "totalSimanja": 1693953.8755879444, + "totalSimpeda": 1595324.2755879443, + "totalDeposito": 1282191.2, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.026349631102592561, + "totalRateGiro": 0.031349631102592562, + "totalRateSimanja": 0.03634963110259256, + "totalRateSimpeda": 0.033849631102592564, + "rateDeposito": 0.0325, + "penaltyRefund": 1299433.8755879444, + "penaltyTax": 324858.46889698616, + "penaltyFee": 324858.4688969861, + "penaltyTotal": 1949150.8133819168, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 3828341.1341738217, + "puabIncome": 223841.09812882188, + "creditIncome": 3604500.036045, + "spreadSimanja": 3125297.3556365278, + "spreadSimpeda": 3248584.3556365278, + "spreadGiro": 3371872.3556365278, + "depositoGross": 1602739 + }, + { + "nominal": 150000000, + "tenor": 6, + "cashbackGross": 2436438.3229223746, + "cashbackNet": 1949150.6583378997, + "giroNet": 295890.4, + "simanjaNet": 591780.8, + "simpedaNet": 443835.2, + "depositoNet": 1923287.2, + "totalGiro": 2245041.0583379, + "totalSimanja": 2540931.4583378998, + "totalSimpeda": 2392985.8583378997, + "totalDeposito": 1923287.2, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.026349629270123458, + "totalRateGiro": 0.031349629270123455, + "totalRateSimanja": 0.03634962927012346, + "totalRateSimpeda": 0.033849629270123457, + "rateDeposito": 0.0325, + "penaltyRefund": 1949150.6583378997, + "penaltyTax": 487287.66458447493, + "penaltyFee": 487287.66458447493, + "penaltyTotal": 2923725.9875068497, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 5742511.6438356163, + "puabIncome": 335761.64383561641, + "creditIncome": 5406750, + "spreadSimanja": 4687944.9791780822, + "spreadSimpeda": 4872876.9791780822, + "spreadGiro": 5057807.9791780822, + "depositoGross": 2404109 + }, + { + "nominal": 250000000, + "tenor": 6, + "cashbackGross": 4060730.6493150685, + "cashbackNet": 3248584.519452055, + "giroNet": 493150.4, + "simanjaNet": 986300.8, + "simpedaNet": 739725.6, + "depositoNet": 3205479.2, + "totalGiro": 3741734.9194520549, + "totalSimanja": 4234885.3194520548, + "totalSimpeda": 3988310.1194520551, + "totalDeposito": 3205479.2, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.026349629991111109, + "totalRateGiro": 0.031349629991111107, + "totalRateSimanja": 0.036349629991111111, + "totalRateSimpeda": 0.033849629991111109, + "rateDeposito": 0.0325, + "penaltyRefund": 3248584.519452055, + "penaltyTax": 812146.12986301375, + "penaltyFee": 812146.12986301375, + "penaltyTotal": 4872876.779178082, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 9570852.7397260275, + "puabIncome": 559602.73972602736, + "creditIncome": 9011250, + "spreadSimanja": 7813242.298630137, + "spreadSimpeda": 8121461.298630137, + "spreadGiro": 8429680.298630137, + "depositoGross": 4006849 + }, + { + "nominal": 250000001, + "tenor": 6, + "cashbackGross": 3654657.6006665747, + "cashbackNet": 2923726.0805332595, + "giroNet": 493150.4, + "simanjaNet": 986300.8, + "simpedaNet": 739725.6, + "depositoNet": 3254794.4, + "totalGiro": 3416876.4805332595, + "totalSimanja": 3910026.8805332594, + "totalSimpeda": 3663451.6805332596, + "totalDeposito": 3254794.4, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.023714667002799991, + "totalRateGiro": 0.028714667002799992, + "totalRateSimanja": 0.033714667002799993, + "totalRateSimpeda": 0.031214667002799991, + "rateDeposito": 0.033, + "penaltyRefund": 2923726.0805332595, + "penaltyTax": 730931.520133315, + "penaltyFee": 730931.52013331489, + "penaltyTotal": 4385589.12079989, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 9570852.7780094389, + "puabIncome": 559602.74196443835, + "creditIncome": 9011250.036045, + "spreadSimanja": 7813242.3348146109, + "spreadSimpeda": 8121461.3348146109, + "spreadGiro": 8429680.33481461, + "depositoGross": 4068493 + }, + { + "nominal": 300000000, + "tenor": 6, + "cashbackGross": 4385588.8312602742, + "cashbackNet": 3508471.0650082193, + "giroNet": 591780.8, + "simanjaNet": 1183561.6, + "simpedaNet": 887671.2, + "depositoNet": 3905752.8, + "totalGiro": 4100251.8650082191, + "totalSimanja": 4692032.665008219, + "totalSimpeda": 4396142.2650082195, + "totalDeposito": 3905752.8, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.023714665532000002, + "totalRateGiro": 0.028714665532000003, + "totalRateSimanja": 0.033714665532000004, + "totalRateSimpeda": 0.031214665532000002, + "rateDeposito": 0.033, + "penaltyRefund": 3508471.0650082193, + "penaltyTax": 877117.76625205483, + "penaltyFee": 877117.76625205483, + "penaltyTotal": 5262706.597512329, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 11485023.287671233, + "puabIncome": 671523.28767123283, + "creditIncome": 10813500, + "spreadSimanja": 9375889.9583561644, + "spreadSimpeda": 9745752.9583561644, + "spreadGiro": 10115615.958356164, + "depositoGross": 4882191 + }, + { + "nominal": 499999999, + "tenor": 6, + "cashbackGross": 7309314.8524841107, + "cashbackNet": 5847451.8819872886, + "giroNet": 986300.8, + "simanjaNet": 1972602.4, + "simpedaNet": 1479452, + "depositoNet": 6509588.8, + "totalGiro": 6833752.6819872884, + "totalSimanja": 7820054.281987289, + "totalSimpeda": 7326903.8819872886, + "totalDeposito": 6509588.8, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.023714666013266668, + "totalRateGiro": 0.028714666013266669, + "totalRateSimanja": 0.03371466601326667, + "totalRateSimpeda": 0.031214666013266668, + "rateDeposito": 0.033, + "penaltyRefund": 5847451.8819872886, + "penaltyTax": 1461862.9704968221, + "penaltyFee": 1461862.9704968221, + "penaltyTotal": 8771177.8229809329, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 19141705.441168644, + "puabIncome": 1119205.4772136437, + "creditIncome": 18022499.963955, + "spreadSimanja": 15626483.561075801, + "spreadSimpeda": 16242921.561075801, + "spreadGiro": 16859360.5610758, + "depositoGross": 8136986 + }, + { + "nominal": 500000000, + "tenor": 6, + "cashbackGross": 7309314.8687671237, + "cashbackNet": 5847451.8950136993, + "giroNet": 986300.8, + "simanjaNet": 1972602.4, + "simpedaNet": 1479452, + "depositoNet": 6509588.8, + "totalGiro": 6833752.6950136991, + "totalSimanja": 7820054.2950137, + "totalSimpeda": 7326903.8950136993, + "totalDeposito": 6509588.8, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.023714666018666668, + "totalRateGiro": 0.028714666018666669, + "totalRateSimanja": 0.03371466601866667, + "totalRateSimpeda": 0.031214666018666667, + "rateDeposito": 0.033, + "penaltyRefund": 5847451.8950136993, + "penaltyTax": 1461862.9737534248, + "penaltyFee": 1461862.9737534248, + "penaltyTotal": 8771177.842520548, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 19141705.479452055, + "puabIncome": 1119205.4794520547, + "creditIncome": 18022500, + "spreadSimanja": 15626483.597260274, + "spreadSimpeda": 16242921.597260274, + "spreadGiro": 16859360.597260274, + "depositoGross": 8136986 + }, + { + "nominal": 500000001, + "tenor": 6, + "cashbackGross": 6662054.6850501373, + "cashbackNet": 5329643.74804011, + "giroNet": 986300.8, + "simanjaNet": 3945204.8, + "simpedaNet": 2958904, + "depositoNet": 6805479.2, + "totalGiro": 6315944.54804011, + "totalSimanja": 9274848.54804011, + "totalSimpeda": 8288547.74804011, + "totalDeposito": 6805479.2, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.021614666268266667, + "totalRateGiro": 0.026614666268266668, + "totalRateSimanja": 0.041614666268266667, + "totalRateSimpeda": 0.036614666268266663, + "rateDeposito": 0.0345, + "penaltyRefund": 5329643.74804011, + "penaltyTax": 1332410.9370100275, + "penaltyFee": 1332410.9370100275, + "penaltyTotal": 7994465.6220601648, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 19141705.517735466, + "puabIncome": 1119205.4816904655, + "creditIncome": 18022500.036045, + "spreadSimanja": 13160730.633444749, + "spreadSimpeda": 14393606.633444749, + "spreadGiro": 16859360.633444749, + "depositoGross": 8506849 + }, + { + "nominal": 750000000, + "tenor": 6, + "cashbackGross": 9993081.703150684, + "cashbackNet": 7994465.3625205476, + "giroNet": 1479452, + "simanjaNet": 5917808, + "simpedaNet": 4438356, + "depositoNet": 10208218.4, + "totalGiro": 9473917.3625205476, + "totalSimanja": 13912273.362520548, + "totalSimpeda": 12432821.362520548, + "totalDeposito": 10208218.4, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.021614665609777775, + "totalRateGiro": 0.026614665609777776, + "totalRateSimanja": 0.041614665609777779, + "totalRateSimpeda": 0.036614665609777775, + "rateDeposito": 0.0345, + "penaltyRefund": 7994465.3625205476, + "penaltyTax": 1998616.3406301369, + "penaltyFee": 1998616.3406301369, + "penaltyTotal": 11991698.04378082, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 28712558.219178081, + "puabIncome": 1678808.219178082, + "creditIncome": 27033750, + "spreadSimanja": 19741094.895890407, + "spreadSimpeda": 21590409.895890407, + "spreadGiro": 25289039.895890407, + "depositoGross": 12760273 + }, + { + "nominal": 999999999, + "tenor": 6, + "cashbackGross": 13324109.021251233, + "cashbackNet": 10659287.217000987, + "giroNet": 1972602.4, + "simanjaNet": 7890410.4, + "simpedaNet": 5917808, + "depositoNet": 13610958.4, + "totalGiro": 12631889.617000988, + "totalSimanja": 18549697.61700099, + "totalSimpeda": 16577095.217000987, + "totalDeposito": 13610958.4, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.0216146657672, + "totalRateGiro": 0.0266146657672, + "totalRateSimanja": 0.0416146657672, + "totalRateSimpeda": 0.0366146657672, + "rateDeposito": 0.0345, + "penaltyRefund": 10659287.217000987, + "penaltyTax": 2664821.8042502468, + "penaltyFee": 2664821.8042502468, + "penaltyTotal": 15988930.825501483, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 38283410.920620695, + "puabIncome": 2238410.9566656984, + "creditIncome": 36044999.963955, + "spreadSimanja": 26321460.158336073, + "spreadSimpeda": 28787213.158336073, + "spreadGiro": 33718720.158336073, + "depositoGross": 17013698 + }, + { + "nominal": 1000000000, + "tenor": 6, + "cashbackGross": 13324109.037534246, + "cashbackNet": 10659287.230027396, + "giroNet": 1972602.4, + "simanjaNet": 7890410.4, + "simpedaNet": 5917808, + "depositoNet": 13610958.4, + "totalGiro": 12631889.630027397, + "totalSimanja": 18549697.6300274, + "totalSimpeda": 16577095.230027396, + "totalDeposito": 13610958.4, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.021614665771999997, + "totalRateGiro": 0.026614665771999998, + "totalRateSimanja": 0.041614665772, + "totalRateSimpeda": 0.036614665771999996, + "rateDeposito": 0.0345, + "penaltyRefund": 10659287.230027396, + "penaltyTax": 2664821.8075068495, + "penaltyFee": 2664821.8075068491, + "penaltyTotal": 15988930.845041096, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 38283410.95890411, + "puabIncome": 2238410.9589041094, + "creditIncome": 36045000, + "spreadSimanja": 26321460.194520548, + "spreadSimpeda": 28787213.194520548, + "spreadGiro": 33718720.194520548, + "depositoGross": 17013698 + }, + { + "nominal": 1000000001, + "tenor": 6, + "cashbackGross": 12584383.003817258, + "cashbackNet": 10067506.403053807, + "giroNet": 1972602.4, + "simanjaNet": 9863013.6, + "simpedaNet": 7890410.4, + "depositoNet": 13808218.4, + "totalGiro": 12040108.803053807, + "totalSimanja": 19930520.003053807, + "totalSimpeda": 17957916.803053807, + "totalDeposito": 13808218.4, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.02041466574133333, + "totalRateGiro": 0.025414665741333331, + "totalRateSimanja": 0.045414665741333335, + "totalRateSimpeda": 0.04041466574133333, + "rateDeposito": 0.035, + "penaltyRefund": 10067506.403053807, + "penaltyTax": 2516876.6007634518, + "penaltyFee": 2516876.6007634518, + "penaltyTotal": 15101259.60458071, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 38283410.997187518, + "puabIncome": 2238410.9611425204, + "creditIncome": 36045000.036045, + "spreadSimanja": 23855706.230705015, + "spreadSimpeda": 26321460.230705015, + "spreadGiro": 33718720.230705015, + "depositoGross": 17260273 + }, + { + "nominal": 1500000000, + "tenor": 6, + "cashbackGross": 18876574.406301368, + "cashbackNet": 15101259.525041094, + "giroNet": 2958904, + "simanjaNet": 14794520, + "simpedaNet": 11835616, + "depositoNet": 20712328, + "totalGiro": 18060163.525041096, + "totalSimanja": 29895779.525041096, + "totalSimpeda": 26936875.525041096, + "totalDeposito": 20712328, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.02041466565422222, + "totalRateGiro": 0.025414665654222221, + "totalRateSimanja": 0.045414665654222225, + "totalRateSimpeda": 0.04041466565422222, + "rateDeposito": 0.035, + "penaltyRefund": 15101259.525041094, + "penaltyTax": 3775314.881260274, + "penaltyFee": 3775314.8812602735, + "penaltyTotal": 22651889.28756164, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 57425116.438356161, + "puabIncome": 3357616.4383561639, + "creditIncome": 54067500, + "spreadSimanja": 35783559.791780815, + "spreadSimpeda": 39482189.791780815, + "spreadGiro": 50578079.791780815, + "depositoGross": 25890410 + }, + { + "nominal": 1999999999, + "tenor": 6, + "cashbackGross": 25168765.808785483, + "cashbackNet": 20135012.647028387, + "giroNet": 3945204.8, + "simanjaNet": 19726027.2, + "simpedaNet": 15780821.6, + "depositoNet": 27616437.6, + "totalGiro": 24080217.447028387, + "totalSimanja": 39861039.84702839, + "totalSimpeda": 35915834.247028388, + "totalDeposito": 27616437.6, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.020414665610666669, + "totalRateGiro": 0.025414665610666669, + "totalRateSimanja": 0.04541466561066667, + "totalRateSimpeda": 0.040414665610666672, + "rateDeposito": 0.035, + "penaltyRefund": 20135012.647028387, + "penaltyTax": 5033753.1617570966, + "penaltyFee": 5033753.1617570966, + "penaltyTotal": 30202518.97054258, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 76566821.879524812, + "puabIncome": 4476821.9155698083, + "creditIncome": 72089999.963955, + "spreadSimanja": 47711412.352856629, + "spreadSimpeda": 52642919.352856629, + "spreadGiro": 67437440.352856621, + "depositoGross": 34520547 + }, + { + "nominal": 2000000000, + "tenor": 6, + "cashbackGross": 16779177.216712326, + "cashbackNet": 13423341.77336986, + "giroNet": 3945204.8, + "simanjaNet": 19726027.2, + "simpedaNet": 15780821.6, + "depositoNet": 27616437.6, + "totalGiro": 17368546.573369861, + "totalSimanja": 33149368.973369859, + "totalSimpeda": 29204163.373369858, + "totalDeposito": 27616437.6, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.013609777075777773, + "totalRateGiro": 0.018609777075777774, + "totalRateSimanja": 0.038609777075777771, + "totalRateSimpeda": 0.033609777075777773, + "rateDeposito": 0.035, + "penaltyRefund": 13423341.77336986, + "penaltyTax": 3355835.4433424654, + "penaltyFee": 3355835.443342465, + "penaltyTotal": 20135012.660054792, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 76566821.91780822, + "puabIncome": 4476821.9178082189, + "creditIncome": 72090000, + "spreadSimanja": 47711412.389041096, + "spreadSimpeda": 52642919.389041096, + "spreadGiro": 67437440.3890411, + "depositoGross": 34520547 + }, + { + "nominal": 2000000001, + "tenor": 6, + "cashbackGross": 16779177.228167664, + "cashbackNet": 13423341.782534132, + "giroNet": 3945204.8, + "simanjaNet": 19726027.2, + "simpedaNet": 15780821.6, + "depositoNet": 27616437.6, + "totalGiro": 17368546.582534131, + "totalSimanja": 33149368.982534133, + "totalSimpeda": 29204163.382534131, + "totalDeposito": 27616437.6, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.013609777078264438, + "totalRateGiro": 0.018609777078264438, + "totalRateSimanja": 0.038609777078264441, + "totalRateSimpeda": 0.033609777078264437, + "rateDeposito": 0.035, + "penaltyRefund": 13423341.782534132, + "penaltyTax": 3355835.4456335329, + "penaltyFee": 3355835.4456335329, + "penaltyTotal": 20135012.6738012, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 76566821.956091613, + "puabIncome": 4476821.9200466285, + "creditIncome": 72090000.036044985, + "spreadSimanja": 47711412.427225552, + "spreadSimpeda": 52642919.427225552, + "spreadGiro": 67437440.42722556, + "depositoGross": 34520547 + }, + { + "nominal": 5000000000, + "tenor": 6, + "cashbackGross": 43747942.791780829, + "cashbackNet": 34998354.233424664, + "giroNet": 9863013.6, + "simanjaNet": 49315068, + "simpedaNet": 39452054.4, + "depositoNet": 69041095.2, + "totalGiro": 44861367.833424665, + "totalSimanja": 84313422.233424664, + "totalSimpeda": 74450408.63342467, + "totalDeposito": 69041095.2, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014193776994666668, + "totalRateGiro": 0.019193776994666668, + "totalRateSimanja": 0.039193776994666668, + "totalRateSimpeda": 0.034193776994666671, + "rateDeposito": 0.035, + "penaltyRefund": 34998354.233424664, + "penaltyTax": 8749588.5583561659, + "penaltyFee": 8749588.5583561659, + "penaltyTotal": 52497531.350136995, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 191417054.79452056, + "puabIncome": 11192054.794520546, + "creditIncome": 180225000, + "spreadSimanja": 125278530.97260275, + "spreadSimpeda": 137607297.97260275, + "spreadGiro": 174593598.97260275, + "depositoGross": 86301369 + }, + { + "nominal": 10000000000, + "tenor": 6, + "cashbackGross": 88695885.48356165, + "cashbackNet": 70956708.386849314, + "giroNet": 19726027.2, + "simanjaNet": 98630136.8, + "simpedaNet": 78904108.8, + "depositoNet": 138082191.2, + "totalGiro": 90682735.586849317, + "totalSimanja": 169586845.1868493, + "totalSimpeda": 149860817.1868493, + "totalDeposito": 138082191.2, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014388443645111111, + "totalRateGiro": 0.019388443645111112, + "totalRateSimanja": 0.039388443645111113, + "totalRateSimpeda": 0.034388443645111108, + "rateDeposito": 0.035, + "penaltyRefund": 70956708.386849314, + "penaltyTax": 17739177.096712332, + "penaltyFee": 17739177.096712328, + "penaltyTotal": 106435062.58027399, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 382834109.58904111, + "puabIncome": 22384109.589041092, + "creditIncome": 360450000, + "spreadSimanja": 254557060.94520551, + "spreadSimpeda": 279214595.94520551, + "spreadGiro": 353187197.94520551, + "depositoGross": 172602739 + }, + { + "nominal": 50000000000, + "tenor": 6, + "cashbackGross": 448279426.81780821, + "cashbackNet": 358623541.45424658, + "giroNet": 98630136.8, + "simanjaNet": 493150684.8, + "simpedaNet": 394520547.2, + "depositoNet": 690410958.4, + "totalGiro": 457253678.25424659, + "totalSimanja": 851774226.25424659, + "totalSimpeda": 753144088.65424657, + "totalDeposito": 690410958.4, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014544176958977778, + "totalRateGiro": 0.019544176958977779, + "totalRateSimanja": 0.039544176958977779, + "totalRateSimpeda": 0.034544176958977782, + "rateDeposito": 0.035, + "penaltyRefund": 358623541.45424658, + "penaltyTax": 89655885.363561645, + "penaltyFee": 89655885.363561645, + "penaltyTotal": 537935312.1813699, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 1914170547.9452055, + "puabIncome": 111920547.94520548, + "creditIncome": 1802250000, + "spreadSimanja": 1288785303.7260275, + "spreadSimpeda": 1412072975.7260275, + "spreadGiro": 1781935988.7260275, + "depositoGross": 863013698 + }, + { + "nominal": 50000000001, + "tenor": 6, + "cashbackGross": 442115043.32926363, + "cashbackNet": 353692034.6634109, + "giroNet": 147945204.8, + "simanjaNet": 493150684.8, + "simpedaNet": 394520547.2, + "depositoNet": 690410958.4, + "totalGiro": 501637239.46341091, + "totalSimanja": 846842719.46341085, + "totalSimpeda": 748212581.863411, + "totalDeposito": 690410958.4, + "rateGiro": 0.0075, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014344176961062558, + "totalRateGiro": 0.021844176961062556, + "totalRateSimanja": 0.039344176961062557, + "totalRateSimpeda": 0.03434417696106256, + "rateDeposito": 0.035, + "penaltyRefund": 353692034.6634109, + "penaltyTax": 88423008.665852726, + "penaltyFee": 88423008.665852726, + "penaltyTotal": 530538051.99511635, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 1914170547.983489, + "puabIncome": 111920547.94744387, + "creditIncome": 1802250000.0360451, + "spreadSimanja": 1288785303.7642121, + "spreadSimpeda": 1412072975.7642121, + "spreadGiro": 1720292153.7642121, + "depositoGross": 863013698 + }, + { + "nominal": 100000000000, + "tenor": 6, + "cashbackGross": 885430086.43561649, + "cashbackNet": 708344069.14849317, + "giroNet": 295890410.4, + "simanjaNet": 986301369.6, + "simpedaNet": 789041095.2, + "depositoNet": 1380821917.6, + "totalGiro": 1004234479.5484931, + "totalSimanja": 1694645438.7484932, + "totalSimpeda": 1497385164.3484931, + "totalDeposito": 1380821917.6, + "rateGiro": 0.0075, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.0143636436244, + "totalRateGiro": 0.0218636436244, + "totalRateSimanja": 0.0393636436244, + "totalRateSimpeda": 0.0343636436244, + "rateDeposito": 0.035, + "penaltyRefund": 708344069.14849317, + "penaltyTax": 177086017.28712332, + "penaltyFee": 177086017.28712329, + "penaltyTotal": 1062516103.7227398, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 3828341095.8904109, + "puabIncome": 223841095.89041096, + "creditIncome": 3604500000, + "spreadSimanja": 2581570607.452055, + "spreadSimpeda": 2828145950.452055, + "spreadGiro": 3444584306.452055, + "depositoGross": 1726027397 + }, + { + "nominal": 100000, + "tenor": 12, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 0, + "simpedaNet": 0, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0, + "rateSimpeda": 0, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 7656.682191780822, + "puabIncome": 447.68219178082188, + "creditIncome": 7209, + "spreadSimanja": 7436.8946389041093, + "spreadSimpeda": 7436.8946389041093, + "spreadGiro": 7436.8946389041093, + "depositoGross": 0 + }, + { + "nominal": 500000, + "tenor": 12, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 2958.4, + "simpedaNet": 1972, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 38283.410958904111, + "puabIncome": 2238.4109589041095, + "creditIncome": 36045, + "spreadSimanja": 33486.473194520549, + "spreadSimpeda": 34719.473194520549, + "spreadGiro": 37184.473194520549, + "depositoGross": 0 + }, + { + "nominal": 999999, + "tenor": 12, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 5917.6, + "simpedaNet": 3944.8, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 76566.7453509863, + "puabIncome": 4476.8174409863013, + "creditIncome": 72089.92791, + "spreadSimanja": 66971.8720200947, + "spreadSimpeda": 69437.8720200947, + "spreadGiro": 74368.8720200947, + "depositoGross": 0 + }, + { + "nominal": 1000000, + "tenor": 12, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 5917.6, + "simpedaNet": 3944.8, + "depositoNet": 26037.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 26037.6, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.033, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 76566.821917808222, + "puabIncome": 4476.821917808219, + "creditIncome": 72090, + "spreadSimanja": 66971.9463890411, + "spreadSimpeda": 69437.9463890411, + "spreadGiro": 74368.9463890411, + "depositoGross": 32547 + }, + { + "nominal": 10000000, + "tenor": 12, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 59177.6, + "simpedaNet": 39452, + "depositoNet": 260383.2, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 260383.2, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.033, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 765668.21917808219, + "puabIncome": 44768.219178082189, + "creditIncome": 720900, + "spreadSimanja": 669717.463890411, + "spreadSimpeda": 694374.463890411, + "spreadGiro": 743689.463890411, + "depositoGross": 325479 + }, + { + "nominal": 49999999, + "tenor": 12, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 295890.4, + "simpedaNet": 197260, + "depositoNet": 1301917.6, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 1301917.6, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.033, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 3828341.0193235893, + "puabIncome": 223841.09141358902, + "creditIncome": 3604499.92791, + "spreadSimanja": 3348584.2450831085, + "spreadSimpeda": 3471872.2450831085, + "spreadGiro": 3718447.2450831085, + "depositoGross": 1627397 + }, + { + "nominal": 50000000, + "tenor": 12, + "cashbackGross": 1756483.9930593607, + "cashbackNet": 1405187.1944474885, + "giroNet": 0, + "simanjaNet": 295890.4, + "simpedaNet": 197260, + "depositoNet": 1301917.6, + "totalGiro": 1405187.1944474885, + "totalSimanja": 1701077.5944474884, + "totalSimpeda": 1602447.1944474885, + "totalDeposito": 1301917.6, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.028494073665185183, + "totalRateGiro": 0.028494073665185183, + "totalRateSimanja": 0.035994073665185186, + "totalRateSimpeda": 0.033494073665185184, + "rateDeposito": 0.033, + "penaltyRefund": 1405187.1944474885, + "penaltyTax": 351296.79861187213, + "penaltyFee": 351296.79861187213, + "penaltyTotal": 2107780.7916712328, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 3828341.0958904112, + "puabIncome": 223841.09589041094, + "creditIncome": 3604500, + "spreadSimanja": 3348584.3194520548, + "spreadSimpeda": 3471872.3194520548, + "spreadGiro": 3718447.3194520548, + "depositoGross": 1627397 + }, + { + "nominal": 55000000, + "tenor": 12, + "cashbackGross": 1886927.1923652971, + "cashbackNet": 1509541.7538922378, + "giroNet": 216985.6, + "simanjaNet": 325479.2, + "simpedaNet": 216985.6, + "depositoNet": 1432108.8, + "totalGiro": 1726527.3538922379, + "totalSimanja": 1835020.9538922377, + "totalSimpeda": 1726527.3538922379, + "totalDeposito": 1432108.8, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.027827411119730645, + "totalRateGiro": 0.032827411119730643, + "totalRateSimanja": 0.035327411119730645, + "totalRateSimpeda": 0.032827411119730643, + "rateDeposito": 0.033, + "penaltyRefund": 1509541.7538922378, + "penaltyTax": 377385.43847305945, + "penaltyFee": 377385.43847305945, + "penaltyTotal": 2264312.6308383564, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 4211175.2054794524, + "puabIncome": 246225.205479452, + "creditIncome": 3964950, + "spreadSimanja": 3683443.0513972607, + "spreadSimpeda": 3819060.0513972607, + "spreadGiro": 3819060.0513972607, + "depositoGross": 1790136 + }, + { + "nominal": 99999999, + "tenor": 12, + "cashbackGross": 3430776.2822675817, + "cashbackNet": 2744621.0258140652, + "giroNet": 394520, + "simanjaNet": 591780.8, + "simpedaNet": 394520, + "depositoNet": 2603835.2, + "totalGiro": 3139141.0258140652, + "totalSimanja": 3336401.8258140655, + "totalSimpeda": 3139141.0258140652, + "totalDeposito": 2603835.2, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.027827407901111129, + "totalRateGiro": 0.032827407901111126, + "totalRateSimanja": 0.035327407901111128, + "totalRateSimpeda": 0.032827407901111126, + "rateDeposito": 0.033, + "penaltyRefund": 2744621.0258140652, + "penaltyTax": 686155.25645351643, + "penaltyFee": 686155.25645351631, + "penaltyTotal": 4116931.5387210981, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 7656682.115214, + "puabIncome": 447682.18730399996, + "creditIncome": 7208999.92791, + "spreadSimanja": 6697168.5645351633, + "spreadSimpeda": 6943744.5645351633, + "spreadGiro": 6943744.5645351633, + "depositoGross": 3254794 + }, + { + "nominal": 100000000, + "tenor": 12, + "cashbackGross": 3430776.3194520548, + "cashbackNet": 2744621.055561644, + "giroNet": 394520, + "simanjaNet": 591780.8, + "simpedaNet": 394520, + "depositoNet": 2603835.2, + "totalGiro": 3139141.055561644, + "totalSimanja": 3336401.8555616438, + "totalSimpeda": 3139141.055561644, + "totalDeposito": 2603835.2, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.027827407924444447, + "totalRateGiro": 0.032827407924444447, + "totalRateSimanja": 0.035327407924444443, + "totalRateSimpeda": 0.032827407924444447, + "rateDeposito": 0.033, + "penaltyRefund": 2744621.055561644, + "penaltyTax": 686155.263890411, + "penaltyFee": 686155.263890411, + "penaltyTotal": 4116931.5833424656, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 7656682.1917808224, + "puabIncome": 447682.19178082189, + "creditIncome": 7209000, + "spreadSimanja": 6697168.63890411, + "spreadSimpeda": 6943744.63890411, + "spreadGiro": 6943744.63890411, + "depositoGross": 3254794 + }, + { + "nominal": 100000001, + "tenor": 12, + "cashbackGross": 3348584.5233031944, + "cashbackNet": 2678867.6186425555, + "giroNet": 394520, + "simanjaNet": 789040.8, + "simpedaNet": 591780.8, + "depositoNet": 2643287.2, + "totalGiro": 3073387.6186425555, + "totalSimanja": 3467908.4186425554, + "totalSimpeda": 3270648.4186425554, + "totalDeposito": 2643287.2, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.027160740861851832, + "totalRateGiro": 0.032160740861851833, + "totalRateSimanja": 0.037160740861851831, + "totalRateSimpeda": 0.034660740861851835, + "rateDeposito": 0.0335, + "penaltyRefund": 2678867.6186425555, + "penaltyTax": 669716.90466063889, + "penaltyFee": 669716.90466063889, + "penaltyTotal": 4018301.4279638333, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 7656682.2683476433, + "puabIncome": 447682.19625764375, + "creditIncome": 7209000.07209, + "spreadSimanja": 6450593.7132730559, + "spreadSimpeda": 6697168.7132730559, + "spreadGiro": 6943744.7132730559, + "depositoGross": 3304109 + }, + { + "nominal": 150000000, + "tenor": 12, + "cashbackGross": 5022876.4791780822, + "cashbackNet": 4018301.1833424657, + "giroNet": 591780.8, + "simanjaNet": 1183561.6, + "simpedaNet": 887671.2, + "depositoNet": 3964931.2, + "totalGiro": 4610081.9833424659, + "totalSimanja": 5201862.7833424658, + "totalSimpeda": 4905972.3833424654, + "totalDeposito": 3964931.2, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.027160739479999998, + "totalRateGiro": 0.03216073948, + "totalRateSimanja": 0.037160739479999996, + "totalRateSimpeda": 0.034660739479999994, + "rateDeposito": 0.0335, + "penaltyRefund": 4018301.1833424657, + "penaltyTax": 1004575.2958356165, + "penaltyFee": 1004575.2958356164, + "penaltyTotal": 6027451.7750136983, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 11485023.287671233, + "puabIncome": 671523.28767123283, + "creditIncome": 10813500, + "spreadSimanja": 9675889.9583561644, + "spreadSimpeda": 10045752.958356164, + "spreadGiro": 10415615.958356164, + "depositoGross": 4956164 + }, + { + "nominal": 250000000, + "tenor": 12, + "cashbackGross": 8371460.965296804, + "cashbackNet": 6697168.7722374434, + "giroNet": 986300.8, + "simanjaNet": 1972602.4, + "simpedaNet": 1479452, + "depositoNet": 6608218.4, + "totalGiro": 7683469.5722374432, + "totalSimanja": 8669771.1722374428, + "totalSimpeda": 8176620.7722374434, + "totalDeposito": 6608218.4, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.027160740020740742, + "totalRateGiro": 0.032160740020740743, + "totalRateSimanja": 0.03716074002074074, + "totalRateSimpeda": 0.034660740020740738, + "rateDeposito": 0.0335, + "penaltyRefund": 6697168.7722374434, + "penaltyTax": 1674292.1930593608, + "penaltyFee": 1674292.1930593608, + "penaltyTotal": 10045753.158356166, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 19141705.479452055, + "puabIncome": 1119205.4794520547, + "creditIncome": 18022500, + "spreadSimanja": 16126483.597260274, + "spreadSimpeda": 16742921.597260274, + "spreadGiro": 17359360.597260274, + "depositoGross": 8260273 + }, + { + "nominal": 250000001, + "tenor": 12, + "cashbackGross": 7534314.90223315, + "cashbackNet": 6027451.92178652, + "giroNet": 986300.8, + "simanjaNet": 1972602.4, + "simpedaNet": 1479452, + "depositoNet": 6706848.8, + "totalGiro": 7013752.7217865195, + "totalSimanja": 8000054.3217865191, + "totalSimpeda": 7506903.92178652, + "totalDeposito": 6706848.8, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.024444666029466666, + "totalRateGiro": 0.029444666029466667, + "totalRateSimanja": 0.034444666029466664, + "totalRateSimpeda": 0.031944666029466662, + "rateDeposito": 0.034, + "penaltyRefund": 6027451.92178652, + "penaltyTax": 1506862.9804466302, + "penaltyFee": 1506862.98044663, + "penaltyTotal": 9041177.88267978, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 19141705.556018878, + "puabIncome": 1119205.4839288767, + "creditIncome": 18022500.07209, + "spreadSimanja": 16126483.671629222, + "spreadSimpeda": 16742921.671629222, + "spreadGiro": 17359360.67162922, + "depositoGross": 8383561 + }, + { + "nominal": 300000000, + "tenor": 12, + "cashbackGross": 9041177.6625205483, + "cashbackNet": 7232942.1300164387, + "giroNet": 1183561.6, + "simanjaNet": 2367123.2, + "simpedaNet": 1775342.4, + "depositoNet": 8048218.4, + "totalGiro": 8416503.7300164383, + "totalSimanja": 9600065.3300164379, + "totalSimpeda": 9008284.530016439, + "totalDeposito": 8048218.4, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.024444665532, + "totalRateGiro": 0.029444665532, + "totalRateSimanja": 0.034444665532, + "totalRateSimpeda": 0.031944665532, + "rateDeposito": 0.034, + "penaltyRefund": 7232942.1300164387, + "penaltyTax": 1808235.5325041097, + "penaltyFee": 1808235.5325041097, + "penaltyTotal": 10849413.195024658, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 22970046.575342465, + "puabIncome": 1343046.5753424657, + "creditIncome": 21627000, + "spreadSimanja": 19351779.916712329, + "spreadSimpeda": 20091505.916712329, + "spreadGiro": 20831231.916712329, + "depositoGross": 10060273 + }, + { + "nominal": 499999999, + "tenor": 12, + "cashbackGross": 15068629.554068221, + "cashbackNet": 12054903.643254576, + "giroNet": 1972602.4, + "simanjaNet": 3945204.8, + "simpedaNet": 2958904, + "depositoNet": 13413698.4, + "totalGiro": 14027506.043254577, + "totalSimanja": 16000108.443254575, + "totalSimpeda": 15013807.643254576, + "totalDeposito": 13413698.4, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.02444466576993333, + "totalRateGiro": 0.029444665769933331, + "totalRateSimanja": 0.034444665769933332, + "totalRateSimpeda": 0.031944665769933329, + "rateDeposito": 0.034, + "penaltyRefund": 12054903.643254576, + "penaltyTax": 3013725.9108136445, + "penaltyFee": 3013725.9108136441, + "penaltyTotal": 18082355.464881863, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 38283410.882337287, + "puabIncome": 2238410.9544272874, + "creditIncome": 36044999.92791, + "spreadSimanja": 32252967.1201516, + "spreadSimpeda": 33485843.1201516, + "spreadGiro": 34718720.1201516, + "depositoGross": 16767123 + }, + { + "nominal": 500000000, + "tenor": 12, + "cashbackGross": 15068629.587534247, + "cashbackNet": 12054903.670027398, + "giroNet": 1972602.4, + "simanjaNet": 3945204.8, + "simpedaNet": 2958904, + "depositoNet": 13413698.4, + "totalGiro": 14027506.070027398, + "totalSimanja": 16000108.470027398, + "totalSimpeda": 15013807.670027398, + "totalDeposito": 13413698.4, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.024444665775333333, + "totalRateGiro": 0.029444665775333334, + "totalRateSimanja": 0.034444665775333332, + "totalRateSimpeda": 0.031944665775333336, + "rateDeposito": 0.034, + "penaltyRefund": 12054903.670027398, + "penaltyTax": 3013725.9175068494, + "penaltyFee": 3013725.9175068494, + "penaltyTotal": 18082355.505041096, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 38283410.95890411, + "puabIncome": 2238410.9589041094, + "creditIncome": 36045000, + "spreadSimanja": 32252967.194520548, + "spreadSimpeda": 33485843.194520548, + "spreadGiro": 34718720.194520548, + "depositoGross": 16767123 + }, + { + "nominal": 500000001, + "tenor": 12, + "cashbackGross": 13774109.071000272, + "cashbackNet": 11019287.256800218, + "giroNet": 1972602.4, + "simanjaNet": 7890410.4, + "simpedaNet": 5917808, + "depositoNet": 13808219.2, + "totalGiro": 12991889.656800218, + "totalSimanja": 18909697.656800218, + "totalSimpeda": 16937095.256800219, + "totalDeposito": 13808219.2, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.022344665781599996, + "totalRateGiro": 0.027344665781599997, + "totalRateSimanja": 0.0423446657816, + "totalRateSimpeda": 0.037344665781599992, + "rateDeposito": 0.035, + "penaltyRefund": 11019287.256800218, + "penaltyTax": 2754821.8142000549, + "penaltyFee": 2754821.8142000544, + "penaltyTotal": 16528930.885200327, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 38283411.035470933, + "puabIncome": 2238410.9633809309, + "creditIncome": 36045000.07209, + "spreadSimanja": 27321460.268889494, + "spreadSimpeda": 29787213.268889494, + "spreadGiro": 34718720.268889494, + "depositoGross": 17260274 + }, + { + "nominal": 750000000, + "tenor": 12, + "cashbackGross": 20661163.406301368, + "cashbackNet": 16528930.725041095, + "giroNet": 2958904, + "simanjaNet": 11835616, + "simpedaNet": 8876712, + "depositoNet": 20712328, + "totalGiro": 19487834.725041095, + "totalSimanja": 28364546.725041095, + "totalSimpeda": 25405642.725041095, + "totalDeposito": 20712328, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.022344665609777777, + "totalRateGiro": 0.027344665609777778, + "totalRateSimanja": 0.042344665609777773, + "totalRateSimpeda": 0.037344665609777776, + "rateDeposito": 0.035, + "penaltyRefund": 16528930.725041095, + "penaltyTax": 4132232.6812602738, + "penaltyFee": 4132232.6812602738, + "penaltyTotal": 24793396.087561641, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 57425116.438356161, + "puabIncome": 3357616.4383561639, + "creditIncome": 54067500, + "spreadSimanja": 40982189.791780815, + "spreadSimpeda": 44680819.791780815, + "spreadGiro": 52078079.791780815, + "depositoGross": 25890410 + }, + { + "nominal": 999999999, + "tenor": 12, + "cashbackGross": 27548217.891602468, + "cashbackNet": 22038574.313281976, + "giroNet": 3945204.8, + "simanjaNet": 15780821.6, + "simpedaNet": 11835616, + "depositoNet": 27616437.6, + "totalGiro": 25983779.113281976, + "totalSimanja": 37819395.913281977, + "totalSimpeda": 33874190.313281976, + "totalDeposito": 27616437.6, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.022344665645533335, + "totalRateGiro": 0.027344665645533336, + "totalRateSimanja": 0.042344665645533339, + "totalRateSimpeda": 0.037344665645533334, + "rateDeposito": 0.035, + "penaltyRefund": 22038574.313281976, + "penaltyTax": 5509643.5783204939, + "penaltyFee": 5509643.5783204939, + "penaltyTotal": 33057861.469922967, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 76566821.84124139, + "puabIncome": 4476821.9133313969, + "creditIncome": 72089999.92791, + "spreadSimanja": 54642919.314672142, + "spreadSimpeda": 59574426.314672142, + "spreadGiro": 69437440.314672142, + "depositoGross": 34520547 + }, + { + "nominal": 1000000000, + "tenor": 12, + "cashbackGross": 27548217.925068494, + "cashbackNet": 22038574.340054795, + "giroNet": 3945204.8, + "simanjaNet": 15780821.6, + "simpedaNet": 11835616, + "depositoNet": 27616437.6, + "totalGiro": 25983779.140054796, + "totalSimanja": 37819395.9400548, + "totalSimpeda": 33874190.340054795, + "totalDeposito": 27616437.6, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.022344665650333332, + "totalRateGiro": 0.027344665650333333, + "totalRateSimanja": 0.042344665650333332, + "totalRateSimpeda": 0.037344665650333328, + "rateDeposito": 0.035, + "penaltyRefund": 22038574.340054795, + "penaltyTax": 5509643.5850136988, + "penaltyFee": 5509643.5850136988, + "penaltyTotal": 33057861.510082193, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 76566821.91780822, + "puabIncome": 4476821.9178082189, + "creditIncome": 72090000, + "spreadSimanja": 54642919.389041096, + "spreadSimpeda": 59574426.389041096, + "spreadGiro": 69437440.3890411, + "depositoGross": 34520547 + }, + { + "nominal": 1000000001, + "tenor": 12, + "cashbackGross": 26068765.858534515, + "cashbackNet": 20855012.686827611, + "giroNet": 3945204.8, + "simanjaNet": 19726027.2, + "simpedaNet": 15780821.6, + "depositoNet": 27616437.6, + "totalGiro": 24800217.486827612, + "totalSimanja": 40581039.88682761, + "totalSimpeda": 36635834.286827609, + "totalDeposito": 27616437.6, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.021144665619666662, + "totalRateGiro": 0.026144665619666663, + "totalRateSimanja": 0.046144665619666667, + "totalRateSimpeda": 0.041144665619666662, + "rateDeposito": 0.035, + "penaltyRefund": 20855012.686827611, + "penaltyTax": 5213753.1717069037, + "penaltyFee": 5213753.1717069028, + "penaltyTotal": 31282519.030241419, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 76566821.994375035, + "puabIncome": 4476821.9222850408, + "creditIncome": 72090000.07209, + "spreadSimanja": 49711412.463410035, + "spreadSimpeda": 54642919.463410035, + "spreadGiro": 69437440.463410035, + "depositoGross": 34520547 + }, + { + "nominal": 1500000000, + "tenor": 12, + "cashbackGross": 39103148.512602739, + "cashbackNet": 31282518.81008219, + "giroNet": 5917808, + "simanjaNet": 29589040.8, + "simpedaNet": 23671232.8, + "depositoNet": 41424656.8, + "totalGiro": 37200326.81008219, + "totalSimanja": 60871559.610082194, + "totalSimpeda": 54953751.610082194, + "totalDeposito": 41424656.8, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.021144665491999996, + "totalRateGiro": 0.026144665491999997, + "totalRateSimanja": 0.046144665492, + "totalRateSimpeda": 0.041144665491999993, + "rateDeposito": 0.035, + "penaltyRefund": 31282518.81008219, + "penaltyTax": 7820629.7025205484, + "penaltyFee": 7820629.7025205474, + "penaltyTotal": 46923778.215123288, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 114850232.87671232, + "puabIncome": 6715232.8767123278, + "creditIncome": 108135000, + "spreadSimanja": 74567118.583561629, + "spreadSimpeda": 81964378.583561629, + "spreadGiro": 104156159.58356163, + "depositoGross": 51780821 + }, + { + "nominal": 1999999999, + "tenor": 12, + "cashbackGross": 52137531.46667096, + "cashbackNet": 41710025.173336767, + "giroNet": 7890410.4, + "simanjaNet": 39452054.4, + "simpedaNet": 31561643.2, + "depositoNet": 55232876, + "totalGiro": 49600435.573336765, + "totalSimanja": 81162079.573336765, + "totalSimpeda": 73271668.373336762, + "totalDeposito": 55232876, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.021144665549833332, + "totalRateGiro": 0.026144665549833333, + "totalRateSimanja": 0.046144665549833333, + "totalRateSimpeda": 0.041144665549833329, + "rateDeposito": 0.035, + "penaltyRefund": 41710025.173336767, + "penaltyTax": 10427506.293334194, + "penaltyFee": 10427506.293334192, + "penaltyTotal": 62565037.760005154, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 153133643.75904962, + "puabIncome": 8953643.8311396167, + "creditIncome": 144179999.92791, + "spreadSimanja": 99422824.703713253, + "spreadSimpeda": 109285838.70371325, + "spreadGiro": 138874879.70371324, + "depositoGross": 69041095 + }, + { + "nominal": 2000000000, + "tenor": 12, + "cashbackGross": 34758354.333424658, + "cashbackNet": 27806683.466739725, + "giroNet": 7890410.4, + "simanjaNet": 39452054.4, + "simpedaNet": 31561643.2, + "depositoNet": 55232876, + "totalGiro": 35697093.866739728, + "totalSimanja": 67258737.86673972, + "totalSimpeda": 59368326.666739725, + "totalDeposito": 55232876, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014096443701888887, + "totalRateGiro": 0.019096443701888888, + "totalRateSimanja": 0.039096443701888889, + "totalRateSimpeda": 0.034096443701888884, + "rateDeposito": 0.035, + "penaltyRefund": 27806683.466739725, + "penaltyTax": 6951670.8666849323, + "penaltyFee": 6951670.8666849313, + "penaltyTotal": 41710025.200109586, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 153133643.83561644, + "puabIncome": 8953643.8356164377, + "creditIncome": 144180000, + "spreadSimanja": 99422824.778082192, + "spreadSimpeda": 109285838.77808219, + "spreadGiro": 138874879.77808219, + "depositoGross": 69041095 + }, + { + "nominal": 2000000001, + "tenor": 12, + "cashbackGross": 34758354.356335334, + "cashbackNet": 27806683.485068269, + "giroNet": 7890410.4, + "simanjaNet": 39452054.4, + "simpedaNet": 31561643.2, + "depositoNet": 55232876, + "totalGiro": 35697093.885068268, + "totalSimanja": 67258737.885068268, + "totalSimpeda": 59368326.685068265, + "totalDeposito": 55232876, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.01409644370413222, + "totalRateGiro": 0.019096443704132219, + "totalRateSimanja": 0.039096443704132219, + "totalRateSimpeda": 0.034096443704132222, + "rateDeposito": 0.035, + "penaltyRefund": 27806683.485068269, + "penaltyTax": 6951670.8712670673, + "penaltyFee": 6951670.8712670673, + "penaltyTotal": 41710025.2276024, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 153133643.91218323, + "puabIncome": 8953643.8400932569, + "creditIncome": 144180000.07208997, + "spreadSimanja": 99422824.8544511, + "spreadSimpeda": 109285838.8544511, + "spreadGiro": 138874879.85445109, + "depositoGross": 69041095 + }, + { + "nominal": 5000000000, + "tenor": 12, + "cashbackGross": 88695885.48356165, + "cashbackNet": 70956708.386849314, + "giroNet": 19726027.2, + "simanjaNet": 98630136.8, + "simpedaNet": 78904108.8, + "depositoNet": 138082191.2, + "totalGiro": 90682735.586849317, + "totalSimanja": 169586845.1868493, + "totalSimpeda": 149860817.1868493, + "totalDeposito": 138082191.2, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014388443645111111, + "totalRateGiro": 0.019388443645111112, + "totalRateSimanja": 0.039388443645111113, + "totalRateSimpeda": 0.034388443645111108, + "rateDeposito": 0.035, + "penaltyRefund": 70956708.386849314, + "penaltyTax": 17739177.096712332, + "penaltyFee": 17739177.096712328, + "penaltyTotal": 106435062.58027399, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 382834109.58904111, + "puabIncome": 22384109.589041092, + "creditIncome": 360450000, + "spreadSimanja": 254557060.94520551, + "spreadSimpeda": 279214595.94520551, + "spreadGiro": 353187197.94520551, + "depositoGross": 172602739 + }, + { + "nominal": 10000000000, + "tenor": 12, + "cashbackGross": 178591770.86712334, + "cashbackNet": 142873416.69369867, + "giroNet": 39452054.4, + "simanjaNet": 197260273.6, + "simpedaNet": 157808218.4, + "depositoNet": 276164383.2, + "totalGiro": 182325471.09369868, + "totalSimanja": 340133690.29369867, + "totalSimpeda": 300681635.09369868, + "totalDeposito": 276164383.2, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014485776970333337, + "totalRateGiro": 0.019485776970333338, + "totalRateSimanja": 0.039485776970333342, + "totalRateSimpeda": 0.034485776970333337, + "rateDeposito": 0.035, + "penaltyRefund": 142873416.69369867, + "penaltyTax": 35718354.173424669, + "penaltyFee": 35718354.173424669, + "penaltyTotal": 214310125.040548, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 765668219.17808223, + "puabIncome": 44768219.178082183, + "creditIncome": 720900000, + "spreadSimanja": 513114121.890411, + "spreadSimpeda": 562429190.890411, + "spreadGiro": 710374395.890411, + "depositoGross": 345205479 + }, + { + "nominal": 50000000000, + "tenor": 12, + "cashbackGross": 897758853.53561652, + "cashbackNet": 718207082.82849324, + "giroNet": 197260273.6, + "simanjaNet": 986301369.6, + "simpedaNet": 789041095.2, + "depositoNet": 1380821917.6, + "totalGiro": 915467356.42849326, + "totalSimanja": 1704508452.4284933, + "totalSimpeda": 1507248178.0284934, + "totalDeposito": 1380821917.6, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014563643624022224, + "totalRateGiro": 0.019563643624022225, + "totalRateSimanja": 0.039563643624022225, + "totalRateSimpeda": 0.034563643624022228, + "rateDeposito": 0.035, + "penaltyRefund": 718207082.82849324, + "penaltyTax": 179551770.70712331, + "penaltyFee": 179551770.70712331, + "penaltyTotal": 1077310624.24274, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 3828341095.8904109, + "puabIncome": 223841095.89041096, + "creditIncome": 3604500000, + "spreadSimanja": 2581570607.452055, + "spreadSimpeda": 2828145950.452055, + "spreadGiro": 3567871977.452055, + "depositoGross": 1726027397 + }, + { + "nominal": 50000000001, + "tenor": 12, + "cashbackGross": 885430086.45852721, + "cashbackNet": 708344069.16682172, + "giroNet": 295890410.4, + "simanjaNet": 986301369.6, + "simpedaNet": 789041095.2, + "depositoNet": 1380821917.6, + "totalGiro": 1004234479.5668217, + "totalSimanja": 1694645438.7668219, + "totalSimpeda": 1497385164.3668218, + "totalDeposito": 1380821917.6, + "rateGiro": 0.0075, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.01436364362448439, + "totalRateGiro": 0.02186364362448439, + "totalRateSimanja": 0.039363643624484392, + "totalRateSimpeda": 0.034363643624484394, + "rateDeposito": 0.035, + "penaltyRefund": 708344069.16682172, + "penaltyTax": 177086017.29170546, + "penaltyFee": 177086017.29170543, + "penaltyTotal": 1062516103.7502327, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 3828341095.9669781, + "puabIncome": 223841095.89488775, + "creditIncome": 3604500000.07209, + "spreadSimanja": 2581570607.5284243, + "spreadSimpeda": 2828145950.5284243, + "spreadGiro": 3444584306.5284243, + "depositoGross": 1726027397 + }, + { + "nominal": 100000000000, + "tenor": 12, + "cashbackGross": 1772060172.6712329, + "cashbackNet": 1417648138.1369863, + "giroNet": 591780821.6, + "simanjaNet": 1972602739.2, + "simpedaNet": 1578082191.2, + "depositoNet": 2761643835.2, + "totalGiro": 2009428959.7369862, + "totalSimanja": 3390250877.3369865, + "totalSimpeda": 2995730329.3369865, + "totalDeposito": 2761643835.2, + "rateGiro": 0.0075, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014373376956111109, + "totalRateGiro": 0.021873376956111107, + "totalRateSimanja": 0.039373376956111109, + "totalRateSimpeda": 0.034373376956111111, + "rateDeposito": 0.035, + "penaltyRefund": 1417648138.1369863, + "penaltyTax": 354412034.53424662, + "penaltyFee": 354412034.53424656, + "penaltyTotal": 2126472207.2054796, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 7656682191.7808218, + "puabIncome": 447682191.78082192, + "creditIncome": 7209000000, + "spreadSimanja": 5167141214.90411, + "spreadSimpeda": 5660291899.90411, + "spreadGiro": 6893168611.90411, + "depositoGross": 3452054794 + }, + { + "nominal": 100000, + "tenor": 24, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 0, + "simpedaNet": 0, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0, + "rateSimpeda": 0, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 15313.364383561644, + "puabIncome": 895.36438356164376, + "creditIncome": 14418, + "spreadSimanja": 15073.789277808219, + "spreadSimpeda": 15073.789277808219, + "spreadGiro": 15073.789277808219, + "depositoGross": 0 + }, + { + "nominal": 500000, + "tenor": 24, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 5917.6, + "simpedaNet": 3944.8, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 76566.821917808222, + "puabIncome": 4476.821917808219, + "creditIncome": 72090, + "spreadSimanja": 67971.9463890411, + "spreadSimpeda": 70437.9463890411, + "spreadGiro": 75368.9463890411, + "depositoGross": 0 + }, + { + "nominal": 999999, + "tenor": 24, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 11835.2, + "simpedaNet": 7890.4, + "depositoNet": 0, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 0, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 153133.49070197259, + "puabIncome": 8953.6348819726027, + "creditIncome": 144179.85582, + "spreadSimanja": 135943.74204018939, + "spreadSimpeda": 140874.74204018939, + "spreadGiro": 150737.74204018939, + "depositoGross": 0 + }, + { + "nominal": 1000000, + "tenor": 24, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 11835.2, + "simpedaNet": 7890.4, + "depositoNet": 53654.4, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 53654.4, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 153133.64383561644, + "puabIncome": 8953.6438356164381, + "creditIncome": 144180, + "spreadSimanja": 135943.8927780822, + "spreadSimpeda": 140874.8927780822, + "spreadGiro": 150737.8927780822, + "depositoGross": 67068 + }, + { + "nominal": 10000000, + "tenor": 24, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 118356, + "simpedaNet": 78904, + "depositoNet": 536547.2, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 536547.2, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 1531336.4383561644, + "puabIncome": 89536.438356164377, + "creditIncome": 1441800, + "spreadSimanja": 1359433.9277808219, + "spreadSimpeda": 1408748.9277808219, + "spreadGiro": 1507378.9277808219, + "depositoGross": 670684 + }, + { + "nominal": 49999999, + "tenor": 24, + "cashbackGross": null, + "cashbackNet": null, + "giroNet": 0, + "simanjaNet": 591780.8, + "simpedaNet": 394520, + "depositoNet": 2682739.2, + "totalGiro": null, + "totalSimanja": null, + "totalSimpeda": null, + "totalDeposito": 2682739.2, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": null, + "totalRateGiro": null, + "totalRateSimanja": null, + "totalRateSimpeda": null, + "rateDeposito": 0.034, + "penaltyRefund": null, + "penaltyTax": null, + "penaltyFee": null, + "penaltyTotal": null, + "shareNasabah": null, + "shareBank": null, + "income": 7656682.0386471786, + "puabIncome": 447682.18282717804, + "creditIncome": 7208999.85582, + "spreadSimanja": 6797168.4881662168, + "spreadSimpeda": 7043744.4881662177, + "spreadGiro": 7536894.4881662177, + "depositoGross": 3353424 + }, + { + "nominal": 50000000, + "tenor": 24, + "cashbackGross": 3562967.9861187213, + "cashbackNet": 2850374.388894977, + "giroNet": 0, + "simanjaNet": 591780.8, + "simpedaNet": 394520, + "depositoNet": 2682739.2, + "totalGiro": 2850374.388894977, + "totalSimanja": 3442155.1888949769, + "totalSimpeda": 3244894.388894977, + "totalDeposito": 2682739.2, + "rateGiro": 0, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.028899629220740738, + "totalRateGiro": 0.028899629220740738, + "totalRateSimanja": 0.036399629220740734, + "totalRateSimpeda": 0.033899629220740739, + "rateDeposito": 0.034, + "penaltyRefund": 2850374.388894977, + "penaltyTax": 712593.59722374426, + "penaltyFee": 712593.59722374426, + "penaltyTotal": 4275561.5833424656, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 7656682.1917808224, + "puabIncome": 447682.19178082189, + "creditIncome": 7209000, + "spreadSimanja": 6797168.63890411, + "spreadSimpeda": 7043744.63890411, + "spreadGiro": 7536894.63890411, + "depositoGross": 3353424 + }, + { + "nominal": 55000000, + "tenor": 24, + "cashbackGross": 3828854.0513972607, + "cashbackNet": 3063083.2411178085, + "giroNet": 433972, + "simanjaNet": 650958.4, + "simpedaNet": 433972, + "depositoNet": 2951013.6, + "totalGiro": 3497055.2411178085, + "totalSimanja": 3714041.6411178084, + "totalSimpeda": 3497055.2411178085, + "totalDeposito": 2951013.6, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.02823296421737374, + "totalRateGiro": 0.033232964217373741, + "totalRateSimanja": 0.035732964217373736, + "totalRateSimpeda": 0.033232964217373741, + "rateDeposito": 0.034, + "penaltyRefund": 3063083.2411178085, + "penaltyTax": 765770.81027945224, + "penaltyFee": 765770.81027945213, + "penaltyTotal": 4594624.8616767125, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 8422350.4109589048, + "puabIncome": 492450.410958904, + "creditIncome": 7929900, + "spreadSimanja": 7476886.1027945215, + "spreadSimpeda": 7748119.1027945215, + "spreadGiro": 7748119.1027945215, + "depositoGross": 3688767 + }, + { + "nominal": 99999999, + "tenor": 24, + "cashbackGross": 6961552.23020183, + "cashbackNet": 5569241.7841614643, + "giroNet": 789040.8, + "simanjaNet": 1183561.6, + "simpedaNet": 789040.8, + "depositoNet": 5365479.2, + "totalGiro": 6358282.5841614641, + "totalSimanja": 6752803.3841614649, + "totalSimpeda": 6358282.5841614641, + "totalDeposito": 5365479.2, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.02823296210481482, + "totalRateGiro": 0.033232962104814821, + "totalRateSimanja": 0.035732962104814817, + "totalRateSimpeda": 0.033232962104814821, + "rateDeposito": 0.034, + "penaltyRefund": 5569241.7841614643, + "penaltyTax": 1392310.4460403661, + "penaltyFee": 1392310.4460403661, + "penaltyTotal": 8353862.676242196, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 15313364.230428, + "puabIncome": 895364.37460799993, + "creditIncome": 14417999.85582, + "spreadSimanja": 13594337.127070328, + "spreadSimpeda": 14087488.127070326, + "spreadGiro": 14087488.127070326, + "depositoGross": 6706849 + }, + { + "nominal": 100000000, + "tenor": 24, + "cashbackGross": 6961552.3055707766, + "cashbackNet": 5569241.8444566214, + "giroNet": 789040.8, + "simanjaNet": 1183561.6, + "simpedaNet": 789040.8, + "depositoNet": 5365479.2, + "totalGiro": 6358282.6444566213, + "totalSimanja": 6752803.444456622, + "totalSimpeda": 6358282.6444566213, + "totalDeposito": 5365479.2, + "rateGiro": 0.005, + "rateSimanja": 0.0075, + "rateSimpeda": 0.005, + "equiv": 0.028232962128148152, + "totalRateGiro": 0.03323296212814815, + "totalRateSimanja": 0.035732962128148152, + "totalRateSimpeda": 0.03323296212814815, + "rateDeposito": 0.034, + "penaltyRefund": 5569241.8444566214, + "penaltyTax": 1392310.4611141554, + "penaltyFee": 1392310.4611141554, + "penaltyTotal": 8353862.7666849317, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 15313364.383561645, + "puabIncome": 895364.38356164377, + "creditIncome": 14418000, + "spreadSimanja": 13594337.277808219, + "spreadSimpeda": 14087488.277808219, + "spreadGiro": 14087488.277808219, + "depositoGross": 6706849 + }, + { + "nominal": 100000001, + "tenor": 24, + "cashbackGross": 6797168.8809397221, + "cashbackNet": 5437735.1047517778, + "giroNet": 789040.8, + "simanjaNet": 1578081.6, + "simpedaNet": 1183561.6, + "depositoNet": 5444383.2, + "totalGiro": 6226775.9047517776, + "totalSimanja": 7015816.7047517784, + "totalSimpeda": 6621296.7047517784, + "totalDeposito": 5444383.2, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.027566295741481471, + "totalRateGiro": 0.032566295741481469, + "totalRateSimanja": 0.037566295741481473, + "totalRateSimpeda": 0.035066295741481471, + "rateDeposito": 0.0345, + "penaltyRefund": 5437735.1047517778, + "penaltyTax": 1359433.7761879445, + "penaltyFee": 1359433.7761879445, + "penaltyTotal": 8156602.6571276663, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 15313364.536695287, + "puabIncome": 895364.3925152875, + "creditIncome": 14418000.14418, + "spreadSimanja": 13101187.428546112, + "spreadSimpeda": 13594337.42854611, + "spreadGiro": 14087488.42854611, + "depositoGross": 6805479 + }, + { + "nominal": 150000000, + "tenor": 24, + "cashbackGross": 10195752.958356164, + "cashbackNet": 8156602.3666849313, + "giroNet": 1183561.6, + "simanjaNet": 2367123.2, + "simpedaNet": 1775342.4, + "depositoNet": 8166575.2, + "totalGiro": 9340163.9666849319, + "totalSimanja": 10523725.566684932, + "totalSimpeda": 9931944.76668493, + "totalDeposito": 8166575.2, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.027566295035555553, + "totalRateGiro": 0.032566295035555554, + "totalRateSimanja": 0.037566295035555551, + "totalRateSimpeda": 0.035066295035555556, + "rateDeposito": 0.0345, + "penaltyRefund": 8156602.3666849313, + "penaltyTax": 2039150.5916712331, + "penaltyFee": 2039150.5916712328, + "penaltyTotal": 12234903.550027397, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 22970046.575342465, + "puabIncome": 1343046.5753424657, + "creditIncome": 21627000, + "spreadSimanja": 19651779.916712329, + "spreadSimpeda": 20391505.916712329, + "spreadGiro": 21131231.916712329, + "depositoGross": 10208219 + }, + { + "nominal": 250000000, + "tenor": 24, + "cashbackGross": 16992921.763926942, + "cashbackNet": 13594337.411141554, + "giroNet": 1972602.4, + "simanjaNet": 3945204.8, + "simpedaNet": 2958904, + "depositoNet": 13610958.4, + "totalGiro": 15566939.811141554, + "totalSimanja": 17539542.211141553, + "totalSimpeda": 16553241.411141554, + "totalDeposito": 13610958.4, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.027566295305925928, + "totalRateGiro": 0.032566295305925926, + "totalRateSimanja": 0.03756629530592593, + "totalRateSimpeda": 0.035066295305925928, + "rateDeposito": 0.0345, + "penaltyRefund": 13594337.411141554, + "penaltyTax": 3398584.3527853885, + "penaltyFee": 3398584.3527853885, + "penaltyTotal": 20391506.116712332, + "shareNasabah": 0.5, + "shareBank": 0.5, + "income": 38283410.95890411, + "puabIncome": 2238410.9589041094, + "creditIncome": 36045000, + "spreadSimanja": 32752967.194520548, + "spreadSimpeda": 33985843.194520548, + "spreadGiro": 35218720.194520548, + "depositoGross": 17013698 + }, + { + "nominal": 250000001, + "tenor": 24, + "cashbackGross": 15293629.655366302, + "cashbackNet": 12234903.724293042, + "giroNet": 1972602.4, + "simanjaNet": 3945204.8, + "simpedaNet": 2958904, + "depositoNet": 13808219.2, + "totalGiro": 14207506.124293042, + "totalSimanja": 16180108.524293043, + "totalSimpeda": 15193807.724293042, + "totalDeposito": 13808219.2, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.024809665786133337, + "totalRateGiro": 0.029809665786133338, + "totalRateSimanja": 0.034809665786133336, + "totalRateSimpeda": 0.03230966578613334, + "rateDeposito": 0.035, + "penaltyRefund": 12234903.724293042, + "penaltyTax": 3058725.9310732605, + "penaltyFee": 3058725.9310732605, + "penaltyTotal": 18352355.586439561, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 38283411.112037756, + "puabIncome": 2238410.9678577534, + "creditIncome": 36045000.14418, + "spreadSimanja": 32752967.345258445, + "spreadSimpeda": 33985843.345258445, + "spreadGiro": 35218720.345258445, + "depositoGross": 17260274 + }, + { + "nominal": 300000000, + "tenor": 24, + "cashbackGross": 18352355.325041097, + "cashbackNet": 14681884.260032877, + "giroNet": 2367123.2, + "simanjaNet": 4734246.4, + "simpedaNet": 3550684.8, + "depositoNet": 16569862.4, + "totalGiro": 17049007.460032877, + "totalSimanja": 19416130.660032876, + "totalSimpeda": 18232569.060032878, + "totalDeposito": 16569862.4, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.024809665531999998, + "totalRateGiro": 0.029809665532, + "totalRateSimanja": 0.034809665531999996, + "totalRateSimpeda": 0.032309665532, + "rateDeposito": 0.035, + "penaltyRefund": 14681884.260032877, + "penaltyTax": 3670471.0650082193, + "penaltyFee": 3670471.0650082193, + "penaltyTotal": 22022826.390049316, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 45940093.15068493, + "puabIncome": 2686093.1506849313, + "creditIncome": 43254000, + "spreadSimanja": 39303559.833424658, + "spreadSimpeda": 40783011.833424658, + "spreadGiro": 42262463.833424658, + "depositoGross": 20712328 + }, + { + "nominal": 499999999, + "tenor": 24, + "cashbackGross": 30587258.957236439, + "cashbackNet": 24469807.16578915, + "giroNet": 3945204.8, + "simanjaNet": 7890410.4, + "simpedaNet": 5917808, + "depositoNet": 27616437.6, + "totalGiro": 28415011.96578915, + "totalSimanja": 32360217.565789148, + "totalSimpeda": 30387615.16578915, + "totalDeposito": 27616437.6, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.024809665648266664, + "totalRateGiro": 0.029809665648266665, + "totalRateSimanja": 0.034809665648266666, + "totalRateSimpeda": 0.032309665648266664, + "rateDeposito": 0.035, + "penaltyRefund": 24469807.16578915, + "penaltyTax": 6117451.7914472884, + "penaltyFee": 6117451.7914472874, + "penaltyTotal": 36704710.748683728, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 76566821.764674574, + "puabIncome": 4476821.9088545749, + "creditIncome": 72089999.85582, + "spreadSimanja": 65505933.2383032, + "spreadSimpeda": 67971686.2383032, + "spreadGiro": 70437440.2383032, + "depositoGross": 34520547 + }, + { + "nominal": 500000000, + "tenor": 24, + "cashbackGross": 30587259.025068495, + "cashbackNet": 24469807.220054798, + "giroNet": 3945204.8, + "simanjaNet": 7890410.4, + "simpedaNet": 5917808, + "depositoNet": 27616437.6, + "totalGiro": 28415012.0200548, + "totalSimanja": 32360217.620054796, + "totalSimpeda": 30387615.220054798, + "totalDeposito": 27616437.6, + "rateGiro": 0.005, + "rateSimanja": 0.01, + "rateSimpeda": 0.0075, + "equiv": 0.024809665653666668, + "totalRateGiro": 0.029809665653666668, + "totalRateSimanja": 0.034809665653666666, + "totalRateSimpeda": 0.032309665653666664, + "rateDeposito": 0.035, + "penaltyRefund": 24469807.220054798, + "penaltyTax": 6117451.8050136995, + "penaltyFee": 6117451.8050136995, + "penaltyTotal": 36704710.8300822, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 76566821.91780822, + "puabIncome": 4476821.9178082189, + "creditIncome": 72090000, + "spreadSimanja": 65505933.389041096, + "spreadSimpeda": 67971686.3890411, + "spreadGiro": 70437440.3890411, + "depositoGross": 34520547 + }, + { + "nominal": 500000001, + "tenor": 24, + "cashbackGross": 27998217.992900547, + "cashbackNet": 22398574.394320436, + "giroNet": 3945204.8, + "simanjaNet": 15780821.6, + "simpedaNet": 11835616, + "depositoNet": 27616438.4, + "totalGiro": 26343779.194320437, + "totalSimanja": 38179395.994320437, + "totalSimpeda": 34234190.394320436, + "totalDeposito": 27616438.4, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.022709665659933331, + "totalRateGiro": 0.027709665659933332, + "totalRateSimanja": 0.042709665659933331, + "totalRateSimpeda": 0.037709665659933334, + "rateDeposito": 0.035, + "penaltyRefund": 22398574.394320436, + "penaltyTax": 5599643.59858011, + "penaltyFee": 5599643.598580109, + "penaltyTotal": 33597861.591480657, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 76566822.070941865, + "puabIncome": 4476821.9267618619, + "creditIncome": 72090000.14418, + "spreadSimanja": 55642919.539778993, + "spreadSimpeda": 60574426.539778993, + "spreadGiro": 70437440.539778993, + "depositoGross": 34520548 + }, + { + "nominal": 750000000, + "tenor": 24, + "cashbackGross": 41997326.66260273, + "cashbackNet": 33597861.330082186, + "giroNet": 5917808, + "simanjaNet": 23671232.8, + "simpedaNet": 17753424, + "depositoNet": 41424656.8, + "totalGiro": 39515669.330082186, + "totalSimanja": 57269094.13008219, + "totalSimpeda": 51351285.330082186, + "totalDeposito": 41424656.8, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.022709665528666663, + "totalRateGiro": 0.027709665528666664, + "totalRateSimanja": 0.04270966552866666, + "totalRateSimpeda": 0.037709665528666662, + "rateDeposito": 0.035, + "penaltyRefund": 33597861.330082186, + "penaltyTax": 8399465.3325205464, + "penaltyFee": 8399465.3325205464, + "penaltyTotal": 50396791.995123275, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 114850232.87671232, + "puabIncome": 6715232.8767123278, + "creditIncome": 108135000, + "spreadSimanja": 83464378.583561629, + "spreadSimpeda": 90861639.583561629, + "spreadGiro": 105656159.58356163, + "depositoGross": 51780821 + }, + { + "nominal": 999999999, + "tenor": 24, + "cashbackGross": 55996435.482304931, + "cashbackNet": 44797148.385843948, + "giroNet": 7890410.4, + "simanjaNet": 31561643.2, + "simpedaNet": 23671232.8, + "depositoNet": 55232876, + "totalGiro": 52687558.785843946, + "totalSimanja": 76358791.585843951, + "totalSimpeda": 68468381.185843945, + "totalDeposito": 55232876, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.022709665523866666, + "totalRateGiro": 0.027709665523866667, + "totalRateSimanja": 0.042709665523866666, + "totalRateSimpeda": 0.037709665523866662, + "rateDeposito": 0.035, + "penaltyRefund": 44797148.385843948, + "penaltyTax": 11199287.096460987, + "penaltyFee": 11199287.096460987, + "penaltyTotal": 67195722.578765914, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 153133643.68248278, + "puabIncome": 8953643.8266627938, + "creditIncome": 144179999.85582, + "spreadSimanja": 111285838.62734428, + "spreadSimpeda": 121148851.62734428, + "spreadGiro": 140874879.62734428, + "depositoGross": 69041095 + }, + { + "nominal": 1000000000, + "tenor": 24, + "cashbackGross": 55996435.550136983, + "cashbackNet": 44797148.440109588, + "giroNet": 7890410.4, + "simanjaNet": 31561643.2, + "simpedaNet": 23671232.8, + "depositoNet": 55232876, + "totalGiro": 52687558.840109587, + "totalSimanja": 76358791.640109584, + "totalSimpeda": 68468381.2401096, + "totalDeposito": 55232876, + "rateGiro": 0.005, + "rateSimanja": 0.02, + "rateSimpeda": 0.015, + "equiv": 0.022709665528666666, + "totalRateGiro": 0.027709665528666667, + "totalRateSimanja": 0.042709665528666667, + "totalRateSimpeda": 0.037709665528666669, + "rateDeposito": 0.035, + "penaltyRefund": 44797148.440109588, + "penaltyTax": 11199287.110027397, + "penaltyFee": 11199287.110027397, + "penaltyTotal": 67195722.660164386, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 153133643.83561644, + "puabIncome": 8953643.8356164377, + "creditIncome": 144180000, + "spreadSimanja": 111285838.77808219, + "spreadSimpeda": 121148851.77808219, + "spreadGiro": 140874879.77808219, + "depositoGross": 69041095 + }, + { + "nominal": 1000000001, + "tenor": 24, + "cashbackGross": 53037531.567969032, + "cashbackNet": 42430025.254375227, + "giroNet": 7890410.4, + "simanjaNet": 39452054.4, + "simpedaNet": 31561643.2, + "depositoNet": 55232876, + "totalGiro": 50320435.654375225, + "totalSimanja": 81882079.654375225, + "totalSimpeda": 73991668.454375222, + "totalDeposito": 55232876, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.021509665558833331, + "totalRateGiro": 0.026509665558833332, + "totalRateSimanja": 0.046509665558833332, + "totalRateSimpeda": 0.041509665558833328, + "rateDeposito": 0.035, + "penaltyRefund": 42430025.254375227, + "penaltyTax": 10607506.313593807, + "penaltyFee": 10607506.313593807, + "penaltyTotal": 63645037.881562836, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 153133643.98875007, + "puabIncome": 8953643.8445700817, + "creditIncome": 144180000.14418, + "spreadSimanja": 101422824.92882007, + "spreadSimpeda": 111285838.92882007, + "spreadGiro": 140874879.92882007, + "depositoGross": 69041095 + }, + { + "nominal": 1500000000, + "tenor": 24, + "cashbackGross": 79556297.025205478, + "cashbackNet": 63645037.620164379, + "giroNet": 11835616, + "simanjaNet": 59178081.6, + "simpedaNet": 47342465.6, + "depositoNet": 82849314.4, + "totalGiro": 75480653.620164379, + "totalSimanja": 122823119.22016439, + "totalSimpeda": 110987503.22016439, + "totalDeposito": 82849314.4, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.021509665492, + "totalRateGiro": 0.026509665492, + "totalRateSimanja": 0.046509665492, + "totalRateSimpeda": 0.041509665492, + "rateDeposito": 0.035, + "penaltyRefund": 63645037.620164379, + "penaltyTax": 15911259.405041097, + "penaltyFee": 15911259.405041095, + "penaltyTotal": 95467556.430246577, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 229700465.75342464, + "puabIncome": 13430465.753424656, + "creditIncome": 216270000, + "spreadSimanja": 152134237.16712326, + "spreadSimpeda": 166928757.16712326, + "spreadGiro": 211312319.16712326, + "depositoGross": 103561643 + }, + { + "nominal": 1999999999, + "tenor": 24, + "cashbackGross": 106075062.63244192, + "cashbackNet": 84860050.105953544, + "giroNet": 15780821.6, + "simanjaNet": 78904108.8, + "simpedaNet": 63123287.2, + "depositoNet": 110465752.8, + "totalGiro": 100640871.70595354, + "totalSimanja": 163764158.90595353, + "totalSimpeda": 147983337.30595356, + "totalDeposito": 110465752.8, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.021509665489, + "totalRateGiro": 0.026509665489000002, + "totalRateSimanja": 0.046509665489, + "totalRateSimpeda": 0.041509665489, + "rateDeposito": 0.035, + "penaltyRefund": 84860050.105953544, + "penaltyTax": 21215012.526488386, + "penaltyFee": 21215012.526488386, + "penaltyTotal": 127290075.15893033, + "shareNasabah": 0.45, + "shareBank": 0.55, + "income": 306267287.51809925, + "puabIncome": 17907287.662279233, + "creditIncome": 288359999.85582, + "spreadSimanja": 202845649.4054265, + "spreadSimpeda": 222571676.4054265, + "spreadGiro": 281749758.4054265, + "depositoGross": 138082191 + }, + { + "nominal": 2000000000, + "tenor": 24, + "cashbackGross": 70716708.466849312, + "cashbackNet": 56573366.773479447, + "giroNet": 15780821.6, + "simanjaNet": 78904108.8, + "simpedaNet": 63123287.2, + "depositoNet": 110465752.8, + "totalGiro": 72354188.373479441, + "totalSimanja": 135477475.57347944, + "totalSimpeda": 119696653.97347945, + "totalDeposito": 110465752.8, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014339776994666665, + "totalRateGiro": 0.019339776994666665, + "totalRateSimanja": 0.039339776994666668, + "totalRateSimpeda": 0.034339776994666664, + "rateDeposito": 0.035, + "penaltyRefund": 56573366.773479447, + "penaltyTax": 14143341.693369864, + "penaltyFee": 14143341.693369862, + "penaltyTotal": 84860050.160219178, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 306267287.67123288, + "puabIncome": 17907287.671232875, + "creditIncome": 288360000, + "spreadSimanja": 202845649.55616438, + "spreadSimpeda": 222571676.55616438, + "spreadGiro": 281749758.55616438, + "depositoGross": 138082191 + }, + { + "nominal": 2000000001, + "tenor": 24, + "cashbackGross": 70716708.412670657, + "cashbackNet": 56573366.730136529, + "giroNet": 15780821.6, + "simanjaNet": 78904109.6, + "simpedaNet": 63123287.2, + "depositoNet": 110465752.8, + "totalGiro": 72354188.330136523, + "totalSimanja": 135477476.33013654, + "totalSimpeda": 119696653.93013653, + "totalDeposito": 110465752.8, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.01433977697651055, + "totalRateGiro": 0.019339776976510549, + "totalRateSimanja": 0.039339776976510553, + "totalRateSimpeda": 0.034339776976510548, + "rateDeposito": 0.035, + "penaltyRefund": 56573366.730136529, + "penaltyTax": 14143341.682534132, + "penaltyFee": 14143341.682534132, + "penaltyTotal": 84860050.095204785, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 306267287.82436645, + "puabIncome": 17907287.680186514, + "creditIncome": 288360000.14417994, + "spreadSimanja": 202845648.70890221, + "spreadSimpeda": 222571676.70890221, + "spreadGiro": 281749758.70890218, + "depositoGross": 138082191 + }, + { + "nominal": 5000000000, + "tenor": 24, + "cashbackGross": 178591770.86712334, + "cashbackNet": 142873416.69369867, + "giroNet": 39452054.4, + "simanjaNet": 197260273.6, + "simpedaNet": 157808218.4, + "depositoNet": 276164383.2, + "totalGiro": 182325471.09369868, + "totalSimanja": 340133690.29369867, + "totalSimpeda": 300681635.09369868, + "totalDeposito": 276164383.2, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014485776970333337, + "totalRateGiro": 0.019485776970333338, + "totalRateSimanja": 0.039485776970333342, + "totalRateSimpeda": 0.034485776970333337, + "rateDeposito": 0.035, + "penaltyRefund": 142873416.69369867, + "penaltyTax": 35718354.173424669, + "penaltyFee": 35718354.173424669, + "penaltyTotal": 214310125.040548, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 765668219.17808223, + "puabIncome": 44768219.178082183, + "creditIncome": 720900000, + "spreadSimanja": 513114121.890411, + "spreadSimpeda": 562429190.890411, + "spreadGiro": 710374395.890411, + "depositoGross": 345205479 + }, + { + "nominal": 10000000000, + "tenor": 24, + "cashbackGross": 358383541.63424659, + "cashbackNet": 286706833.30739725, + "giroNet": 78904108.8, + "simanjaNet": 394520547.2, + "simpedaNet": 315616437.6, + "depositoNet": 552328766.4, + "totalGiro": 365610942.10739726, + "totalSimanja": 681227380.50739717, + "totalSimpeda": 602323270.90739727, + "totalDeposito": 552328766.4, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014534443632944444, + "totalRateGiro": 0.019534443632944445, + "totalRateSimanja": 0.039534443632944449, + "totalRateSimpeda": 0.034534443632944445, + "rateDeposito": 0.035, + "penaltyRefund": 286706833.30739725, + "penaltyTax": 71676708.326849326, + "penaltyFee": 71676708.326849312, + "penaltyTotal": 430060249.96109593, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 1531336438.3561645, + "puabIncome": 89536438.356164366, + "creditIncome": 1441800000, + "spreadSimanja": 1030228243.780822, + "spreadSimpeda": 1128858380.780822, + "spreadGiro": 1424748791.780822, + "depositoGross": 690410958 + }, + { + "nominal": 50000000000, + "tenor": 24, + "cashbackGross": 1796717706.9712327, + "cashbackNet": 1437374165.5769861, + "giroNet": 394520547.2, + "simanjaNet": 1972602739.2, + "simpedaNet": 1578082191.2, + "depositoNet": 2761643835.2, + "totalGiro": 1831894712.7769861, + "totalSimanja": 3409976904.7769861, + "totalSimpeda": 3015456356.7769861, + "totalDeposito": 2761643835.2, + "rateGiro": 0.005, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014573376956544442, + "totalRateGiro": 0.019573376956544441, + "totalRateSimanja": 0.039573376956544441, + "totalRateSimpeda": 0.034573376956544444, + "rateDeposito": 0.035, + "penaltyRefund": 1437374165.5769861, + "penaltyTax": 359343541.39424658, + "penaltyFee": 359343541.39424652, + "penaltyTotal": 2156061248.365479, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 7656682191.7808218, + "puabIncome": 447682191.78082192, + "creditIncome": 7209000000, + "spreadSimanja": 5167141214.90411, + "spreadSimpeda": 5660291899.90411, + "spreadGiro": 7139743954.90411, + "depositoGross": 3452054794 + }, + { + "nominal": 50000000001, + "tenor": 24, + "cashbackGross": 1772060172.7170544, + "cashbackNet": 1417648138.1736436, + "giroNet": 591780821.6, + "simanjaNet": 1972602739.2, + "simpedaNet": 1578082191.2, + "depositoNet": 2761643835.2, + "totalGiro": 2009428959.7736435, + "totalSimanja": 3390250877.3736439, + "totalSimpeda": 2995730329.3736439, + "totalDeposito": 2761643835.2, + "rateGiro": 0.0075, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014373376956195307, + "totalRateGiro": 0.021873376956195307, + "totalRateSimanja": 0.039373376956195305, + "totalRateSimpeda": 0.034373376956195308, + "rateDeposito": 0.035, + "penaltyRefund": 1417648138.1736436, + "penaltyTax": 354412034.5434109, + "penaltyFee": 354412034.5434109, + "penaltyTotal": 2126472207.2604651, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 7656682191.9339561, + "puabIncome": 447682191.78977549, + "creditIncome": 7209000000.14418, + "spreadSimanja": 5167141215.0568485, + "spreadSimpeda": 5660291900.0568485, + "spreadGiro": 6893168612.0568485, + "depositoGross": 3452054794 + }, + { + "nominal": 100000000000, + "tenor": 24, + "cashbackGross": 3545320345.1424661, + "cashbackNet": 2836256276.1139727, + "giroNet": 1183561643.2, + "simanjaNet": 3945205479.2, + "simpedaNet": 3156164383.2, + "depositoNet": 5523287671.2, + "totalGiro": 4019817919.3139725, + "totalSimanja": 6781461755.3139725, + "totalSimpeda": 5992420659.3139725, + "totalDeposito": 5523287671.2, + "rateGiro": 0.0075, + "rateSimanja": 0.025, + "rateSimpeda": 0.02, + "equiv": 0.014378243621966666, + "totalRateGiro": 0.021878243621966664, + "totalRateSimanja": 0.039378243621966666, + "totalRateSimpeda": 0.034378243621966668, + "rateDeposito": 0.035, + "penaltyRefund": 2836256276.1139727, + "penaltyTax": 709064069.02849329, + "penaltyFee": 709064069.02849317, + "penaltyTotal": 4254384414.1709595, + "shareNasabah": 0.3, + "shareBank": 0.7, + "income": 15313364383.561644, + "puabIncome": 895364383.56164384, + "creditIncome": 14418000000, + "spreadSimanja": 10338282428.80822, + "spreadSimpeda": 11324583798.80822, + "spreadGiro": 13790337223.80822, + "depositoGross": 6904109589 + } + ] +} diff --git a/tests/export-excel-fixtures.ps1 b/tests/export-excel-fixtures.ps1 new file mode 100644 index 0000000..3d82cb6 --- /dev/null +++ b/tests/export-excel-fixtures.ps1 @@ -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 ] +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) diff --git a/tests/legacy-original.js b/tests/legacy-original.js new file mode 100644 index 0000000..c8b6494 --- /dev/null +++ b/tests/legacy-original.js @@ -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 + }; +}; diff --git a/tests/validate.js b/tests/validate.js new file mode 100644 index 0000000..7994d1c --- /dev/null +++ b/tests/validate.js @@ -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; diff --git a/tests/validate_engine.php b/tests/validate_engine.php new file mode 100644 index 0000000..eec91d1 --- /dev/null +++ b/tests/validate_engine.php @@ -0,0 +1,70 @@ + $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); diff --git a/tests/validate_public.js b/tests/validate_public.js new file mode 100644 index 0000000..a2a7d8a --- /dev/null +++ b/tests/validate_public.js @@ -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);