asd
This commit is contained in:
commit
3e4a056b14
67 files changed
+13926
No files matched your search
@@ -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.
|
||||
<LimitExcept GET POST HEAD>
|
||||
Require all denied
|
||||
</LimitExcept>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
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]
|
||||
</IfModule>
|
||||
|
||||
# Internal files are never served over HTTP.
|
||||
<FilesMatch "^(config\.php|error_log|composer\.(json|lock)|package(-lock)?\.json)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
<FilesMatch "\.(md|sql|ps1|sh|bat|cmd|ini|log|env|dist|bak|old|orig|save|swp|swo|tmp|zip|tar|gz|7z|rar|xlsx|csv|json)$|~$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
# 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(/|$)
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header always unset X-Powered-By
|
||||
Header unset X-Powered-By
|
||||
# JS/CSS/fonts/images are always requested with ?v=<version>-<mtime>, so a new
|
||||
# upload changes the URL and clients fetch it immediately; old URLs can be
|
||||
# cached for a long time.
|
||||
<FilesMatch "\.(js|css|woff2|png|svg)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||
Header set X-Content-Type-Options "nosniff"
|
||||
Header set Cross-Origin-Resource-Policy "same-origin"
|
||||
</FilesMatch>
|
||||
# The service worker must be revalidated on every visit so updates reach the field.
|
||||
<FilesMatch "^sw\.js$">
|
||||
Header set Cache-Control "no-cache"
|
||||
</FilesMatch>
|
||||
<FilesMatch "\.svg$">
|
||||
Header set Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
# php -- BEGIN cPanel-generated handler, do not edit
|
||||
# Set the “alt-php82” package as the default “PHP” programming language.
|
||||
<IfModule mime_module>
|
||||
AddHandler application/x-httpd-alt-php82___lsphp .php .php8 .phtml
|
||||
</IfModule>
|
||||
# php -- END cPanel-generated handler, do not edit
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Secrets folder: never served over HTTP.
|
||||
Require all denied
|
||||
@@ -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
|
||||
@@ -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.
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Product
|
||||
|
||||
<!-- impeccable:product-schema 1 -->
|
||||
|
||||
## 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.
|
||||
@@ -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=<versi>-<waktu ubah file>` (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 `<meta name="app-version">`.
|
||||
|
||||
## 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`.
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// Second login step (mandatory 2FA): verify the authenticator code, or set the
|
||||
// authenticator up on the first login. Recovery codes are shown once afterwards.
|
||||
require __DIR__ . '/../includes/auth.php';
|
||||
send_security_headers(true);
|
||||
|
||||
// Recovery codes right after activation (already logged in, shown once).
|
||||
if (current_admin() && isset($_GET['done'])) {
|
||||
$codes = $_SESSION['recovery_once'] ?? null;
|
||||
unset($_SESSION['recovery_once']);
|
||||
if (!$codes) {
|
||||
redirect(base_url('admin/'));
|
||||
}
|
||||
$mode = 'done';
|
||||
} elseif (current_admin()) {
|
||||
redirect(base_url('admin/'));
|
||||
} else {
|
||||
$pending = pending_login();
|
||||
if (!$pending) {
|
||||
redirect(base_url('admin/login.php?reason=expired'));
|
||||
}
|
||||
[$row, $mode] = $pending;
|
||||
}
|
||||
|
||||
$error = null;
|
||||
$configError = null;
|
||||
|
||||
try {
|
||||
if ($mode === 'verify' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$code = strtoupper(trim(input_str($_POST['code'] ?? '')));
|
||||
if (otp_failures($row['username']) >= 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.';
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Verifikasi dua langkah · SERUBI</title>
|
||||
<?= theme_boot_script() ?>
|
||||
<link rel="icon" href="<?= e(asset('assets/favicon.svg')) ?>" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="<?= e(asset('assets/css/base.css')) ?>">
|
||||
<link rel="stylesheet" href="<?= e(asset('assets/css/admin.css')) ?>">
|
||||
</head>
|
||||
<body class="page-login">
|
||||
<main class="login">
|
||||
<a class="brand" href="<?= e(base_url()) ?>" aria-label="Bank Papua SERUBI, kembali ke kalkulator"><?= bank_logo('auto', 'logo-lg') ?><span class="brand-prog">SERUBI</span></a>
|
||||
|
||||
<?php if ($mode === 'done'): ?>
|
||||
<section class="login-card">
|
||||
<h1>Simpan kode pemulihan</h1>
|
||||
<p class="muted">Autentikasi dua faktor aktif. Bila ponsel hilang, masuk dengan salah satu kode di bawah (masing-masing hanya bisa dipakai sekali). Kode ini <strong>hanya ditampilkan sekarang</strong>: cetak atau simpan di tempat aman.</p>
|
||||
<ol class="recovery-codes">
|
||||
<?php foreach ($codes as $c): ?><li><code><?= e($c) ?></code></li><?php endforeach; ?>
|
||||
</ol>
|
||||
<a class="btn btn-primary btn-block" href="<?= e(base_url('admin/')) ?>"><?= icon('check') ?><span>Sudah saya simpan, lanjut ke admin</span></a>
|
||||
</section>
|
||||
|
||||
<?php else: ?>
|
||||
<form class="login-card" method="post" novalidate>
|
||||
<?= csrf_field() ?>
|
||||
<?php if ($mode === 'enroll'): ?>
|
||||
<h1>Aktifkan autentikasi dua faktor</h1>
|
||||
<p class="muted">Login admin wajib memakai kode OTP dari aplikasi authenticator (Google Authenticator, Microsoft Authenticator, atau sejenisnya).</p>
|
||||
<?php else: ?>
|
||||
<h1>Verifikasi dua langkah</h1>
|
||||
<p class="muted">Ketik 6 digit kode dari aplikasi authenticator untuk akun <strong>@<?= e($row['username']) ?></strong>.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($configError): ?>
|
||||
<p class="alert alert-error" role="alert"><?= icon('alert') ?><span><?= e($configError) ?></span></p>
|
||||
<?php else: ?>
|
||||
<?php if ($error): ?>
|
||||
<p class="alert alert-error" role="alert"><?= icon('alert') ?><span><?= e($error) ?></span></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($mode === 'enroll'): ?>
|
||||
<ol class="otp-steps">
|
||||
<li>Buka aplikasi authenticator, pilih tambah akun, lalu pindai QR code ini.
|
||||
<div class="otp-qr" data-otpauth="<?= e(totp_uri($secret, $row['username'])) ?>" role="img" aria-label="QR code untuk aplikasi authenticator"></div>
|
||||
</li>
|
||||
<li>Tidak bisa memindai? Masukkan kunci ini secara manual (tipe: berbasis waktu):
|
||||
<code class="otp-secret"><?= e(trim(chunk_split($secret, 4, ' '))) ?></code>
|
||||
</li>
|
||||
<li>Ketik 6 digit kode yang muncul di aplikasi.</li>
|
||||
</ol>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="code"><?= $mode === 'enroll' ? 'Kode dari aplikasi' : 'Kode OTP' ?></label>
|
||||
<input id="code" name="code" required autofocus autocomplete="one-time-code" inputmode="<?= $mode === 'enroll' ? 'numeric' : 'text' ?>" maxlength="9" spellcheck="false" autocapitalize="characters">
|
||||
<?php if ($mode === 'verify'): ?><p class="hint">Ponsel hilang? Ketik salah satu kode pemulihan (format XXXX-XXXX).</p><?php endif; ?>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" type="submit"><?= icon('lock') ?><span><?= $mode === 'enroll' ? 'Aktifkan & masuk' : 'Verifikasi' ?></span></button>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<form method="post" action="<?= e(base_url('admin/logout.php')) ?>">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="back-link text-btn"><?= icon('x') ?><span>Batal</span></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
<script src="<?= e(asset('assets/js/lib/qrcode.js')) ?>"></script>
|
||||
<script src="<?= e(asset('assets/js/otp.js')) ?>"></script>
|
||||
<script src="<?= e(asset('assets/js/theme.js')) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../includes/auth.php';
|
||||
require __DIR__ . '/../includes/captcha.php';
|
||||
|
||||
if (!captcha_available()) {
|
||||
http_response_code(503);
|
||||
exit;
|
||||
}
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cross-Origin-Resource-Policy: same-origin');
|
||||
captcha_render();
|
||||
@@ -0,0 +1,7 @@
|
||||
[12-Oct-2026 11:40:00 Asia/Jayapura] [serubi] Error: Class "PDO" not found in /home/k5551916/serubi.bankpapua.co.id/includes/db.php:23
|
||||
[12-Oct-2026 11:40:03 Asia/Jayapura] [serubi] Error: Class "PDO" not found in /home/k5551916/serubi.bankpapua.co.id/includes/db.php:23
|
||||
[12-Oct-2026 11:40:04 Asia/Jayapura] [serubi] Error: Class "PDO" not found in /home/k5551916/serubi.bankpapua.co.id/includes/db.php:23
|
||||
[12-Oct-2026 11:48:50 Asia/Jayapura] [serubi] Error: Class "PDO" not found in /home/k5551916/serubi.bankpapua.co.id/includes/db.php:23
|
||||
[12-Oct-2026 11:51:10 Asia/Jayapura] [serubi] Error: Class "PDO" not found in /home/k5551916/serubi.bankpapua.co.id/includes/db.php:23
|
||||
[13-Oct-2026 06:34:36 Asia/Jakarta] PHP Warning: Unsupported declare 'strict_types' in /home/k5551916/serubi.bankpapua.co.id/admin/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/admin/index.php on line 16
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../includes/admin_actions.php';
|
||||
|
||||
define('SERUBI_ADMIN', true);
|
||||
$me = require_admin();
|
||||
|
||||
$tabs = [
|
||||
'parameter' => ['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]) ? '<p class="field-msg" role="alert">' . icon('alert') . '<span>' . e($errors[$key]) . '</span></p>' : '';
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="robots" content="noindex">
|
||||
<title><?= e($tabs[$tab][0]) ?> · Admin SERUBI</title>
|
||||
<?= theme_boot_script() ?>
|
||||
<link rel="icon" href="<?= e(asset('assets/favicon.svg')) ?>" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="<?= e(asset('assets/css/base.css')) ?>">
|
||||
<link rel="stylesheet" href="<?= e(asset('assets/css/admin.css')) ?>">
|
||||
<meta name="app-version" content="<?= e(app_version()) ?>">
|
||||
</head>
|
||||
<body class="page-admin">
|
||||
<a class="skip-link" href="#konten">Lewati ke konten</a>
|
||||
<div class="admin<?= $hasPreview ? ' has-preview' : '' ?>">
|
||||
<aside class="side">
|
||||
<div class="side-top">
|
||||
<a class="brand" href="<?= e(base_url('admin/')) ?>" aria-label="Bank Papua, beranda admin"><?= bank_logo('white') ?></a>
|
||||
<p class="side-tag"><span class="brand-prog">SERUBI</span><span class="side-sub">Admin parameter</span></p>
|
||||
</div>
|
||||
<nav class="side-nav" aria-label="Menu admin">
|
||||
<?php foreach ($tabs as $key => [$label, $ico]): ?>
|
||||
<a href="?tab=<?= e($key) ?>" class="side-link<?= $key === $tab ? ' is-active' : '' ?>" <?= $key === $tab ? 'aria-current="page"' : '' ?>>
|
||||
<?= icon($ico) ?><span><?= e($label) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<a class="side-link" href="<?= e(base_url()) ?>" target="_blank" rel="noopener"><?= icon('calc') ?><span>Buka kalkulator</span></a>
|
||||
<div class="side-user">
|
||||
<span class="side-user-name"><?= e($me['name']) ?></span>
|
||||
<span class="side-user-id">@<?= e($me['username']) ?></span>
|
||||
</div>
|
||||
<div class="side-actions">
|
||||
<button type="button" class="icon-btn" data-theme-toggle aria-label="Ganti tema terang/gelap"><?= icon('moon', 'icon icon-moon') ?><?= icon('sun', 'icon icon-sun') ?></button>
|
||||
<form method="post" action="<?= e(base_url('admin/logout.php')) ?>">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="text-btn"><?= icon('logout') ?><span>Keluar</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="admin-main" id="konten" tabindex="-1">
|
||||
<header class="page-head">
|
||||
<h1><?= e($tabs[$tab][0]) ?></h1>
|
||||
<p><?= e($tabs[$tab][2]) ?></p>
|
||||
<?php if ($hasPreview): ?>
|
||||
<p class="page-meta">Terakhir diubah <?= e(fmt_datetime($params['updatedAt'])) ?><?= $params['updatedBy'] ? ' oleh @' . e($params['updatedBy']) : '' ?></p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
|
||||
<?php if ($me['must_change']): ?>
|
||||
<p class="alert alert-info" role="status"><?= icon('key') ?><span>Password akun Anda baru dibuat atau di-reset. Ganti password di bawah sebelum memakai menu lain.</span></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($flash): ?>
|
||||
<p class="alert alert-<?= e($flash['type']) ?>" role="status"><?= icon($flash['type'] === 'ok' ? 'check' : 'info') ?><span><?= e($flash['message']) ?></span></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($errors && !in_array($tab, ['pengguna'], true)): ?>
|
||||
<p class="alert alert-error" role="alert"><?= icon('alert') ?><span>Perubahan belum disimpan. Periksa isian yang ditandai.</span></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/views/' . $tab . '.php'; ?>
|
||||
</main>
|
||||
|
||||
<?php if ($hasPreview): ?>
|
||||
<aside class="preview" aria-labelledby="pv-title">
|
||||
<div class="preview-card">
|
||||
<h2 id="pv-title">Pratinjau dampak</h2>
|
||||
<p class="muted">Hasil kalkulator dengan nilai di formulir, sebelum disimpan.</p>
|
||||
<div class="pv-inputs">
|
||||
<div class="form-field">
|
||||
<label for="pvNominal">Nominal</label>
|
||||
<div class="input-affix"><span>Rp</span><input id="pvNominal" inputmode="numeric" value="55.000.000" autocomplete="off"></div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="pvTenor">Jangka waktu</label>
|
||||
<select id="pvTenor">
|
||||
<?php foreach ($params['tenors'] as $t): ?><option value="<?= (int)$t ?>" <?= $t === 6 ? 'selected' : '' ?>><?= (int)$t ?> bulan</option><?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<table class="pv-table">
|
||||
<thead><tr><th scope="col"></th><th scope="col">Tersimpan</th><th scope="col">Formulir</th></tr></thead>
|
||||
<tbody id="pvRows"></tbody>
|
||||
</table>
|
||||
<p class="hint-block" id="pvWarn" role="status" hidden><?= icon('alert') ?><span></span></p>
|
||||
</div>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="savebar" id="savebar" hidden>
|
||||
<span><?= icon('info') ?>Ada perubahan yang belum disimpan.</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-ghost" id="btnDiscard">Batalkan</button>
|
||||
<button type="button" class="btn btn-primary" id="btnSave"><?= icon('save') ?><span>Simpan</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?= e(csp_nonce()) ?>">window.SERUBI_PARAMS = <?= json_encode($params, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?>;</script>
|
||||
<script src="<?= e(asset('assets/js/engine.js')) ?>"></script>
|
||||
<script src="<?= e(asset('assets/js/format.js')) ?>"></script>
|
||||
<script src="<?= e(asset('assets/js/admin.js')) ?>"></script>
|
||||
<script src="<?= e(asset('assets/js/theme.js')) ?>"></script>
|
||||
<?php if ($draft !== null): ?><script nonce="<?= e(csp_nonce()) ?>">document.dispatchEvent(new Event('serubi:dirty'));</script><?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../includes/auth.php';
|
||||
require __DIR__ . '/../includes/captcha.php';
|
||||
send_security_headers(true);
|
||||
|
||||
if (current_admin()) {
|
||||
redirect(base_url('admin/'));
|
||||
}
|
||||
if (pending_login()) {
|
||||
redirect(base_url('admin/2fa.php'));
|
||||
}
|
||||
|
||||
$setup = admin_count() === 0; // first run: create the first admin account
|
||||
// The first-admin form is only usable with the setup key from config.php
|
||||
// (or create the admin from the server console). Never open to the internet.
|
||||
$setupKey = (string)security_config()['setup_key'];
|
||||
$setupAllowed = $setup && strlen($setupKey) >= 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();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="robots" content="noindex">
|
||||
<title><?= $setup ? 'Buat Akun Admin' : 'Masuk Admin' ?> · SERUBI</title>
|
||||
<?= theme_boot_script() ?>
|
||||
<link rel="icon" href="<?= e(asset('assets/favicon.svg')) ?>" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="<?= e(asset('assets/css/base.css')) ?>">
|
||||
<link rel="stylesheet" href="<?= e(asset('assets/css/admin.css')) ?>">
|
||||
</head>
|
||||
<body class="page-login">
|
||||
<main class="login">
|
||||
<a class="brand" href="<?= e(base_url()) ?>" aria-label="Bank Papua SERUBI, kembali ke kalkulator"><?= bank_logo('auto', 'logo-lg') ?><span class="brand-prog">SERUBI</span></a>
|
||||
<form class="login-card" method="post" novalidate>
|
||||
<?= csrf_field() ?>
|
||||
<h1><?= $setup ? 'Buat akun admin pertama' : 'Masuk ke admin parameter' ?></h1>
|
||||
<p class="muted"><?= $setup
|
||||
? 'Belum ada admin. Akun ini dapat mengubah parameter perhitungan dan menambah admin lain.'
|
||||
: 'Ubah rate ALCO dan parameter perhitungan kalkulator cashback.' ?></p>
|
||||
|
||||
<?php if ($setup && !$setupAllowed): ?>
|
||||
<p class="alert alert-info" role="status"><?= icon('info') ?><span>Belum ada admin. Buat admin pertama dari konsol server:<br><code>php database/migrate.php --create-admin</code><br>atau isi <code>security.setup_key</code> (minimal 16 karakter) di config.php.</span></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($notice && !$error): ?>
|
||||
<p class="alert alert-info" role="status"><?= icon('info') ?><span><?= e($notice) ?></span></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($error): ?>
|
||||
<p class="alert alert-error" role="alert"><?= icon('alert') ?><span><?= e($error) ?></span></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username" required autocapitalize="none" spellcheck="false"
|
||||
value="<?= e($username) ?>" <?= $username === '' ? 'autofocus' : '' ?>>
|
||||
</div>
|
||||
<?php if ($setupAllowed): ?>
|
||||
<div class="form-field">
|
||||
<label for="setup_key">Kode setup</label>
|
||||
<input id="setup_key" name="setup_key" type="password" required autocomplete="off">
|
||||
<p class="hint">Kode dari <code>security.setup_key</code> di config.php.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($setup): ?>
|
||||
<div class="form-field">
|
||||
<label for="full_name">Nama lengkap</label>
|
||||
<input id="full_name" name="full_name" autocomplete="name" required value="<?= e($name) ?>">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="form-field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required autocomplete="<?= $setup ? 'new-password' : 'current-password' ?>" <?= $username !== '' ? 'autofocus' : '' ?>>
|
||||
<?php if ($setup): ?><p class="hint">Minimal 10 karakter, berisi huruf, angka, dan simbol.</p><?php endif; ?>
|
||||
</div>
|
||||
<?php if ($setup): ?>
|
||||
<div class="form-field">
|
||||
<label for="password_confirm">Ulangi password</label>
|
||||
<input id="password_confirm" name="password_confirm" type="password" required autocomplete="new-password">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!$setup || $setupAllowed): ?>
|
||||
<div class="form-field">
|
||||
<label for="captcha">Kode keamanan</label>
|
||||
<?php if ($captchaQuestion): ?>
|
||||
<p class="hint"><?= e($captchaQuestion) ?></p>
|
||||
<?php else: ?>
|
||||
<img class="captcha-img" src="<?= e(base_url('admin/captcha.php') . '?r=' . bin2hex(random_bytes(4))) ?>" width="200" height="64" alt="Kode keamanan berupa gambar 5 karakter">
|
||||
<p class="hint">Ketik 5 karakter pada gambar. <a href="<?= e(base_url('admin/login.php')) ?>">Ganti gambar</a></p>
|
||||
<?php endif; ?>
|
||||
<input id="captcha" name="captcha" required autocomplete="off" autocapitalize="characters" spellcheck="false" inputmode="<?= $captchaQuestion ? 'numeric' : 'text' ?>" maxlength="8">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" type="submit"><?= icon($setup ? 'check' : 'lock') ?><span><?= $setup ? 'Buat akun & masuk' : 'Masuk' ?></span></button>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<a class="back-link" href="<?= e(base_url()) ?>"><?= icon('calc') ?><span>Kembali ke kalkulator</span></a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../includes/auth.php';
|
||||
send_security_headers(true);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
if (current_admin()) {
|
||||
audit('logout', 'Keluar dari halaman admin');
|
||||
}
|
||||
logout_admin();
|
||||
}
|
||||
redirect(base_url('admin/login.php'));
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
defined('SERUBI_ADMIN') || exit;
|
||||
// Expects: $prefix (input name prefix), $rows (list of ['max' => str, 'rate' => str]), $tierId.
|
||||
$last = count($rows) - 1;
|
||||
?>
|
||||
<div class="tier-editor" data-tiers="<?= e($tierId) ?>" data-prefix="<?= e($prefix) ?>">
|
||||
<table class="tier-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Nominal</th>
|
||||
<th scope="col">Rate p.a.</th>
|
||||
<th scope="col"><span class="visually-hidden">Hapus</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $i => $r): ?>
|
||||
<tr class="tier-row">
|
||||
<td data-label="Nominal">
|
||||
<div class="tier-range">
|
||||
<span class="tier-from"></span>
|
||||
<div class="input-affix is-idr tier-max-wrap">
|
||||
<span>Rp</span>
|
||||
<input name="<?= e($prefix) ?>[<?= $i ?>][max]" class="t-max" value="<?= e($i === $last ? '' : ($r['max'] ?? '')) ?>"
|
||||
inputmode="numeric" autocomplete="off" aria-label="Sampai dengan nominal, baris <?= $i + 1 ?>">
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Rate p.a.">
|
||||
<div class="input-affix tier-rate">
|
||||
<input name="<?= e($prefix) ?>[<?= $i ?>][rate]" class="t-rate" value="<?= e($r['rate'] ?? '') ?>"
|
||||
inputmode="decimal" autocomplete="off" aria-label="Rate persen per tahun, baris <?= $i + 1 ?>">
|
||||
<span>%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="tier-del-cell">
|
||||
<button type="button" class="icon-btn tier-del" aria-label="Hapus baris <?= $i + 1 ?>"><?= icon('trash') ?></button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" class="btn btn-ghost tier-add"><?= icon('plus') ?><span>Tambah tingkat</span></button>
|
||||
</div>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
defined('SERUBI_ADMIN') || exit;
|
||||
if ($draft !== null) {
|
||||
$cbRows = array_values($draft['cb'] ?? []);
|
||||
$cbTenors = $draft['cbtenor'] ?? [];
|
||||
} else {
|
||||
$cbRows = array_map(fn($t) => ['min' => fmt_idr($t['min']), 'share' => fmt_pct($t['nasabah'])], $params['cashbackTiers']);
|
||||
$cbTenors = $params['cashbackTenors'];
|
||||
}
|
||||
?>
|
||||
<form method="post" id="paramForm" data-kind="cashback" novalidate>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_cashback">
|
||||
|
||||
<section class="panel">
|
||||
<header class="panel-head"><h2>Jangka waktu program</h2><p>Hanya jangka waktu yang dicentang yang mendapat cashback.</p></header>
|
||||
<div class="check-row">
|
||||
<?php foreach ($params['tenors'] as $t): ?>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="cbtenor[<?= (int)$t ?>]" value="1" data-cbtenor="<?= (int)$t ?>" <?= in_array($t, $cbTenors, true) ? 'checked' : '' ?>>
|
||||
<span><?= (int)$t ?> bulan</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="panel-head"><h2>Porsi spread untuk nasabah</h2><p>Tingkat dengan nominal minimal tertinggi yang tercapai yang dipakai. Porsi bank = 100% − porsi nasabah. Nominal di bawah tingkat terendah tidak mendapat cashback.</p></header>
|
||||
<?= field_error($errors, 'cb') ?>
|
||||
<table class="tier-table cb-table" id="cbTable">
|
||||
<thead><tr><th scope="col">Nominal minimal</th><th scope="col">Porsi nasabah</th><th scope="col">Porsi bank</th><th scope="col"><span class="visually-hidden">Hapus</span></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($cbRows as $i => $r): ?>
|
||||
<tr class="cb-row">
|
||||
<td data-label="Nominal minimal">
|
||||
<div class="input-affix is-idr"><span>Rp</span><input name="cb[<?= $i ?>][min]" class="cb-min" value="<?= e($r['min'] ?? '') ?>" inputmode="numeric" autocomplete="off" aria-label="Nominal minimal, baris <?= $i + 1 ?>"></div>
|
||||
</td>
|
||||
<td data-label="Porsi nasabah">
|
||||
<div class="input-affix tier-rate"><input name="cb[<?= $i ?>][share]" class="cb-share" value="<?= e($r['share'] ?? '') ?>" inputmode="decimal" autocomplete="off" aria-label="Porsi nasabah persen, baris <?= $i + 1 ?>"><span>%</span></div>
|
||||
</td>
|
||||
<td data-label="Porsi bank" class="cb-bank">—</td>
|
||||
<td class="tier-del-cell"><button type="button" class="icon-btn cb-del" aria-label="Hapus baris <?= $i + 1 ?>"><?= icon('trash') ?></button></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" class="btn btn-ghost cb-add"><?= icon('plus') ?><span>Tambah tingkat</span></button>
|
||||
</section>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit"><?= icon('save') ?><span>Simpan porsi cashback</span></button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
defined('SERUBI_ADMIN') || exit;
|
||||
$tenors = $params['tenors'];
|
||||
if ($draft !== null) {
|
||||
$bands = array_values($draft['bands'] ?? []);
|
||||
$rates = array_values($draft['rates'] ?? []);
|
||||
} else {
|
||||
$bands = array_map('fmt_idr', $params['depositoBands']);
|
||||
$rates = [];
|
||||
foreach ($params['depositoBands'] as $i => $_) {
|
||||
foreach ($tenors as $t) {
|
||||
$rates[$i][$t] = fmt_pct($params['depositoRates'][$t][$i]['rate'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
$last = count($bands) - 1;
|
||||
?>
|
||||
<form method="post" id="paramForm" data-kind="deposito" novalidate>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_deposito">
|
||||
<p class="hint-block"><?= icon('info') ?><span>Nominal di bawah Rp<?= e(fmt_idr($params['settings']['deposito_min'])) ?> tidak mendapat bunga deposito (atur di Parameter bank). Rate dalam persen per tahun.</span></p>
|
||||
<section class="panel">
|
||||
<header class="panel-head"><h2>Tabel rate deposito</h2><p>Baris = kelompok nominal, kolom = jangka waktu.</p></header>
|
||||
<?= field_error($errors, 'deposito') ?>
|
||||
<div class="matrix-scroll" tabindex="0" role="region" aria-label="Tabel rate deposito, dapat digeser">
|
||||
<table class="matrix" id="depoMatrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Nominal</th>
|
||||
<?php foreach ($tenors as $t): ?><th scope="col"><?= (int)$t ?> bln</th><?php endforeach; ?>
|
||||
<th scope="col"><span class="visually-hidden">Hapus</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($bands as $i => $max): ?>
|
||||
<tr class="tier-row">
|
||||
<th scope="row">
|
||||
<div class="tier-range">
|
||||
<span class="tier-from"></span>
|
||||
<div class="input-affix is-idr tier-max-wrap">
|
||||
<span>Rp</span>
|
||||
<input name="bands[<?= $i ?>]" class="t-max" value="<?= e($i === $last ? '' : $max) ?>" inputmode="numeric" autocomplete="off" aria-label="Sampai dengan nominal, baris <?= $i + 1 ?>">
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
<?php foreach ($tenors as $t): ?>
|
||||
<td data-label="<?= (int)$t ?> bulan">
|
||||
<div class="input-affix tier-rate">
|
||||
<input name="rates[<?= $i ?>][<?= (int)$t ?>]" data-tenor="<?= (int)$t ?>" class="t-rate" value="<?= e($rates[$i][$t] ?? '') ?>" inputmode="decimal" autocomplete="off" aria-label="Rate <?= (int)$t ?> bulan, baris <?= $i + 1 ?>">
|
||||
<span>%</span>
|
||||
</div>
|
||||
</td>
|
||||
<?php endforeach; ?>
|
||||
<td class="tier-del-cell"><button type="button" class="icon-btn tier-del" aria-label="Hapus baris <?= $i + 1 ?>"><?= icon('trash') ?></button></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost tier-add" data-target="depoMatrix"><?= icon('plus') ?><span>Tambah kelompok nominal</span></button>
|
||||
</section>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit"><?= icon('save') ?><span>Simpan rate deposito</span></button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
defined('SERUBI_ADMIN') || exit;
|
||||
$rows = settings_rows();
|
||||
$byGroup = [];
|
||||
foreach ($rows as $key => $row) {
|
||||
$byGroup[$row['grp']][$key] = $row;
|
||||
}
|
||||
$unitSuffix = ['pct' => '%', 'idr' => null, 'days' => 'hari'];
|
||||
?>
|
||||
<form method="post" class="param-form" id="paramForm" data-kind="settings" novalidate>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_settings">
|
||||
<?php foreach (SERUBI_GROUPS as $grp => [$title, $desc]): if (empty($byGroup[$grp])) continue; ?>
|
||||
<section class="panel">
|
||||
<header class="panel-head"><h2><?= e($title) ?></h2><p><?= e($desc) ?></p></header>
|
||||
<div class="setting-list">
|
||||
<?php foreach ($byGroup[$grp] as $key => $row):
|
||||
$val = $draft[$key] ?? fmt_setting($row);
|
||||
$err = isset($errors["s.$key"]);
|
||||
$id = 's-' . $key; ?>
|
||||
<div class="setting<?= $err ? ' is-invalid' : '' ?>">
|
||||
<div class="setting-text">
|
||||
<label for="<?= e($id) ?>"><?= e($row['label']) ?></label>
|
||||
<p id="<?= e($id) ?>-d"><?= e($row['description']) ?></p>
|
||||
<?= field_error($errors, "s.$key") ?>
|
||||
</div>
|
||||
<div class="input-affix<?= $row['unit'] === 'idr' ? ' is-idr' : '' ?>">
|
||||
<?php if ($row['unit'] === 'idr'): ?><span>Rp</span><?php endif; ?>
|
||||
<input id="<?= e($id) ?>" name="s[<?= e($key) ?>]" value="<?= e($val) ?>" data-setting="<?= e($key) ?>" data-unit="<?= e($row['unit']) ?>"
|
||||
inputmode="<?= $row['unit'] === 'idr' || $row['unit'] === 'days' ? 'numeric' : 'decimal' ?>" autocomplete="off"
|
||||
aria-describedby="<?= e($id) ?>-d" <?= $err ? 'aria-invalid="true"' : '' ?>>
|
||||
<?php if ($unitSuffix[$row['unit']] ?? null): ?><span><?= e($unitSuffix[$row['unit']]) ?></span><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit"><?= icon('save') ?><span>Simpan parameter</span></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<details class="panel panel-danger"<?= isset($errors['reset']) ? ' open' : '' ?>>
|
||||
<summary><h2>Kembalikan ke nilai file Excel</h2><?= icon('chevron', 'icon chev') ?></summary>
|
||||
<form method="post" class="danger-body">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="reset_defaults">
|
||||
<p>Semua parameter, rate produk, rate deposito, dan porsi cashback diganti dengan nilai dari <b>CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx</b>. Perubahan tercatat di riwayat.</p>
|
||||
<div class="form-field">
|
||||
<label for="confirmReset">Ketik <b>RESET</b> untuk konfirmasi</label>
|
||||
<input id="confirmReset" name="confirm" autocomplete="off" autocapitalize="characters">
|
||||
<?= field_error($errors, 'reset') ?>
|
||||
</div>
|
||||
<button class="btn btn-danger" type="submit"><?= icon('reset') ?><span>Kembalikan semua parameter</span></button>
|
||||
</form>
|
||||
</details>
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
defined('SERUBI_ADMIN') || exit;
|
||||
$users = db()->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 '<div class="form-field"><label for="' . e($id) . '">Password Anda (konfirmasi)</label>'
|
||||
. '<input id="' . e($id) . '" type="password" name="confirm_password" autocomplete="current-password" required></div>';
|
||||
};
|
||||
?>
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2>Daftar admin</h2>
|
||||
<p><?= $super
|
||||
? 'Sebagai super admin, Anda dapat menambah, menonaktifkan, me-reset password atau OTP, dan mengubah peran admin lain.'
|
||||
: 'Hanya super admin yang dapat mengelola akun admin lain.' ?></p>
|
||||
</header>
|
||||
<?= isset($errors['user']) && !$openId ? field_error($errors, 'user') : '' ?>
|
||||
<ul class="user-list">
|
||||
<?php foreach ($users as $u): $self = (int)$u['id'] === $me['id']; $uid = (int)$u['id']; ?>
|
||||
<li class="user<?= (int)$u['is_active'] ? '' : ' is-off' ?>">
|
||||
<div class="user-main">
|
||||
<span class="user-name"><?= e($u['full_name']) ?><?= $self ? ' <span class="badge">Anda</span>' : '' ?>
|
||||
<span class="badge"><?= $u['role'] === 'super_admin' ? 'Super admin' : 'Admin' ?></span>
|
||||
<?= (int)$u['otp_enabled'] ? '' : ' <span class="badge badge-off">OTP belum aktif</span>' ?>
|
||||
<?= (int)$u['must_change_password'] ? ' <span class="badge badge-off">Wajib ganti password</span>' : '' ?>
|
||||
<?= (int)$u['is_active'] ? '' : ' <span class="badge badge-off">Nonaktif</span>' ?></span>
|
||||
<span class="user-meta">@<?= e($u['username']) ?> · login terakhir <?= e(fmt_datetime($u['last_login_at'])) ?></span>
|
||||
<?= $openId === $uid ? field_error($errors, 'user') : '' ?>
|
||||
</div>
|
||||
<?php if ($super && !$self): ?>
|
||||
<div class="user-actions">
|
||||
<details class="inline-details"<?= $openId === $uid ? ' open' : '' ?>>
|
||||
<summary class="btn btn-ghost btn-sm"><?= icon('key') ?><span>Kelola</span></summary>
|
||||
<form method="post" class="inline-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="user_password">
|
||||
<input type="hidden" name="id" value="<?= $uid ?>">
|
||||
<strong>Reset password</strong>
|
||||
<div class="form-field"><label for="pw<?= $uid ?>">Password sementara</label><input id="pw<?= $uid ?>" type="password" name="password" autocomplete="new-password" required></div>
|
||||
<div class="form-field"><label for="pc<?= $uid ?>">Ulangi password</label><input id="pc<?= $uid ?>" type="password" name="password_confirm" autocomplete="new-password" required></div>
|
||||
<?= $confirmField('pa' . $uid) ?>
|
||||
<p class="hint">Pemilik akun wajib mengganti password ini saat login berikutnya.</p>
|
||||
<button class="btn btn-primary btn-sm" type="submit">Reset password</button>
|
||||
</form>
|
||||
<form method="post" class="inline-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= $uid ?>">
|
||||
<strong>Akun</strong>
|
||||
<?= $confirmField('pb' . $uid) ?>
|
||||
<div class="inline-buttons">
|
||||
<button class="btn btn-sm <?= (int)$u['is_active'] ? 'btn-ghost' : 'btn-primary' ?>" type="submit" name="action" value="user_toggle"><?= (int)$u['is_active'] ? 'Nonaktifkan' : 'Aktifkan' ?></button>
|
||||
<button class="btn btn-ghost btn-sm" type="submit" name="action" value="user_role"><?= $u['role'] === 'super_admin' ? 'Jadikan admin' : 'Jadikan super admin' ?></button>
|
||||
<?php if ((int)$u['otp_enabled']): ?>
|
||||
<button class="btn btn-ghost btn-sm" type="submit" name="action" value="user_reset_otp">Reset OTP</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="hint">Reset OTP bila ponsel hilang: authenticator baru dipasang saat login berikutnya.</p>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="two-col">
|
||||
<?php if ($super): ?>
|
||||
<section class="panel">
|
||||
<header class="panel-head"><h2>Tambah admin</h2></header>
|
||||
<form method="post" class="stack-form" novalidate>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="user_create">
|
||||
<?= field_error($errors, 'user_new') ?>
|
||||
<div class="form-field"><label for="nu">Username</label><input id="nu" name="username" autocapitalize="none" spellcheck="false" autocomplete="off" value="<?= e($draft['username'] ?? '') ?>" required><p class="hint">Huruf kecil, angka, titik, minus, garis bawah.</p></div>
|
||||
<div class="form-field"><label for="nn">Nama lengkap</label><input id="nn" name="full_name" autocomplete="off" value="<?= e($draft['full_name'] ?? '') ?>" required></div>
|
||||
<div class="form-field"><label for="nr">Peran</label>
|
||||
<select id="nr" name="role"><option value="admin">Admin (ubah parameter)</option><option value="super_admin">Super admin (juga kelola akun)</option></select></div>
|
||||
<div class="form-field"><label for="np">Password awal</label><input id="np" type="password" name="password" autocomplete="new-password" required><p class="hint">Minimal 10 karakter, berisi huruf, angka, dan simbol. Wajib diganti saat login pertama.</p></div>
|
||||
<div class="form-field"><label for="nc">Ulangi password</label><input id="nc" type="password" name="password_confirm" autocomplete="new-password" required></div>
|
||||
<?= $confirmField('nx') ?>
|
||||
<button class="btn btn-primary" type="submit"><?= icon('plus') ?><span>Tambah admin</span></button>
|
||||
</form>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="panel">
|
||||
<header class="panel-head"><h2>Password Anda</h2></header>
|
||||
<form method="post" class="stack-form" novalidate>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="own_password">
|
||||
<?= field_error($errors, 'own') ?>
|
||||
<div class="form-field"><label for="oc">Password saat ini</label><input id="oc" type="password" name="current" autocomplete="current-password" required></div>
|
||||
<div class="form-field"><label for="op">Password baru</label><input id="op" type="password" name="password" autocomplete="new-password" required></div>
|
||||
<div class="form-field"><label for="oq">Ulangi password baru</label><input id="oq" type="password" name="password_confirm" autocomplete="new-password" required></div>
|
||||
<button class="btn btn-primary" type="submit"><?= icon('key') ?><span>Ganti password</span></button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
defined('SERUBI_ADMIN') || exit;
|
||||
// Rows as display strings: from the failed POST, or from the database.
|
||||
$tierRows = [];
|
||||
foreach (SERUBI_PRODUCTS as $key => $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]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
<form method="post" id="paramForm" data-kind="products" novalidate>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_products">
|
||||
<p class="hint-block"><?= icon('info') ?><span>Tingkat dibaca dari atas: nominal sampai dengan batas pertama memakai rate pertama, dan seterusnya. Baris terakhir berlaku untuk semua nominal di atasnya.</span></p>
|
||||
<?php foreach (SERUBI_PRODUCTS as $key => $label): ?>
|
||||
<section class="panel">
|
||||
<header class="panel-head"><h2><?= e($label) ?></h2></header>
|
||||
<?= field_error($errors, "tiers.$key") ?>
|
||||
<?php $prefix = "tiers[$key]"; $rows = $tierRows[$key]; $tierId = $key; require __DIR__ . '/_tiers.php'; ?>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit"><?= icon('save') ?><span>Simpan rate produk</span></button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
defined('SERUBI_ADMIN') || exit;
|
||||
$perPage = 30;
|
||||
$page = max(1, (int)($_GET['p'] ?? 1));
|
||||
$total = (int)db()->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));
|
||||
};
|
||||
?>
|
||||
<section class="panel">
|
||||
<?php if (!$logs): ?>
|
||||
<p class="empty">Belum ada aktivitas. Setiap perubahan parameter, login, dan pengelolaan admin akan tercatat di sini.</p>
|
||||
<?php else: ?>
|
||||
<ol class="log-list">
|
||||
<?php foreach ($logs as $log): $ch = $log['changes'] ? json_decode($log['changes'], true) : null; ?>
|
||||
<li class="log">
|
||||
<div class="log-head">
|
||||
<span class="log-what"><?= e($log['summary']) ?></span>
|
||||
<span class="log-meta"><?= e(fmt_datetime($log['created_at'])) ?> · <?= $log['username'] ? '@' . e($log['username']) : 'sistem' ?></span>
|
||||
</div>
|
||||
<?php if ($ch && $log['action'] === 'param.settings'): ?>
|
||||
<ul class="log-diff">
|
||||
<?php foreach ($ch as $d): ?>
|
||||
<li><?= e($d['label']) ?>: <del><?= e($fmtVal($d['old'], $d['unit'])) ?></del> → <ins><?= e($fmtVal($d['new'], $d['unit'])) ?></ins></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php elseif ($ch && $log['action'] === 'param.produk'): ?>
|
||||
<ul class="log-diff">
|
||||
<?php foreach ($ch as $prod => $d): ?>
|
||||
<li><b><?= e(SERUBI_PRODUCTS[$prod] ?? $prod) ?></b><br><del><?= e($fmtTiers($d['old'])) ?></del><br><ins><?= e($fmtTiers($d['new'])) ?></ins></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php elseif ($ch && $log['action'] === 'param.deposito'): ?>
|
||||
<ul class="log-diff">
|
||||
<?php foreach ($ch['new'] as $tenor => $tiers): if (json_encode($tiers) === json_encode($ch['old'][$tenor] ?? null)) continue; ?>
|
||||
<li><b><?= (int)$tenor ?> bulan</b><br><del><?= e($fmtTiers($ch['old'][$tenor] ?? [])) ?></del><br><ins><?= e($fmtTiers($tiers)) ?></ins></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php elseif ($ch && $log['action'] === 'param.cashback'): ?>
|
||||
<ul class="log-diff">
|
||||
<?php foreach (['old' => 'del', 'new' => 'ins'] as $k => $tag): ?>
|
||||
<li><<?= $tag ?>>Tenor <?= e(implode(', ', $ch[$k]['tenors'])) ?> bln · <?= e(implode(' · ', array_map(fn($t) => '≥ Rp' . fmt_idr((float)$t['min']) . ': ' . fmt_pct((float)$t['nasabah']) . '%', $ch[$k]['tiers']))) ?></<?= $tag ?>></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
<?php if ($pages > 1): ?>
|
||||
<nav class="pager" aria-label="Halaman riwayat">
|
||||
<?php if ($page > 1): ?><a class="btn btn-ghost btn-sm" href="?tab=riwayat&p=<?= $page - 1 ?>">Lebih baru</a><?php endif; ?>
|
||||
<span>Halaman <?= $page ?> dari <?= $pages ?></span>
|
||||
<?php if ($page < $pages): ?><a class="btn btn-ghost btn-sm" href="?tab=riwayat&p=<?= $page + 1 ?>">Lebih lama</a><?php endif; ?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
@@ -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; } }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0b2e66"/><rect x="8" y="6" width="16" height="20" rx="2.5" fill="none" stroke="#f2c14e" stroke-width="2"/><rect x="11" y="9" width="10" height="4" rx="1" fill="#f2c14e"/><g fill="#eef3fb"><circle cx="12" cy="17" r="1.3"/><circle cx="16" cy="17" r="1.3"/><circle cx="20" cy="17" r="1.3"/><circle cx="12" cy="21.5" r="1.3"/><circle cx="16" cy="21.5" r="1.3"/><circle cx="20" cy="21.5" r="1.3"/></g></svg>
|
||||
|
After Width: | Height: | Size: 506 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,221 @@
|
||||
/* Admin page: tier editors, unsaved-change bar, and live impact preview. */
|
||||
(function () {
|
||||
'use strict';
|
||||
var F = window.SerubiFormat;
|
||||
var E = window.SerubiEngine;
|
||||
var saved = window.SERUBI_PARAMS;
|
||||
var form = document.getElementById('paramForm');
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var clone = function (o) { return JSON.parse(JSON.stringify(o)); };
|
||||
|
||||
// ---- thousands formatting for every Rupiah input ----
|
||||
document.addEventListener('input', function (ev) {
|
||||
var el = ev.target;
|
||||
if (el.matches && el.matches('.is-idr input, #pvNominal')) F.liveFormatIdr(el);
|
||||
});
|
||||
|
||||
// ---- tier editors (product tiers and deposito matrix share the row logic) ----
|
||||
function tierRows(editor) { return Array.prototype.slice.call(editor.querySelectorAll('tbody .tier-row')); }
|
||||
|
||||
function refreshTiers(editor) {
|
||||
var rows = tierRows(editor);
|
||||
var prev = 0;
|
||||
rows.forEach(function (row, i) {
|
||||
var last = i === rows.length - 1;
|
||||
var maxInput = row.querySelector('.t-max');
|
||||
var wrap = row.querySelector('.tier-max-wrap');
|
||||
var from = row.querySelector('.tier-from');
|
||||
if (last) {
|
||||
from.textContent = rows.length === 1 ? 'Semua nominal' : 'Di atas Rp' + F.num(prev);
|
||||
wrap.hidden = true;
|
||||
maxInput.value = '';
|
||||
} else {
|
||||
from.textContent = i === 0 ? 'Sampai dengan' : 'Rp' + F.num(prev + 1) + ' s.d.';
|
||||
wrap.hidden = false;
|
||||
prev = F.parseIdr(maxInput.value) || prev;
|
||||
}
|
||||
// renumber names: prefix[i][field] or bands[i] / rates[i][tenor]
|
||||
row.querySelectorAll('input[name]').forEach(function (inp) {
|
||||
inp.name = inp.name.replace(/^(\w+(?:\[[a-z_]\w*\])?)\[\d+\]/, '$1[' + i + ']');
|
||||
});
|
||||
var del = row.querySelector('.tier-del');
|
||||
if (del) { del.disabled = rows.length === 1; del.setAttribute('aria-label', 'Hapus baris ' + (i + 1)); }
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-tiers], #depoMatrix').forEach(function (ed) {
|
||||
var editor = ed.id === 'depoMatrix' ? ed.closest('.panel') : ed;
|
||||
refreshTiers(editor);
|
||||
editor.addEventListener('input', function (ev) { if (ev.target.classList.contains('t-max')) refreshTiers(editor); });
|
||||
editor.addEventListener('click', function (ev) {
|
||||
var del = ev.target.closest('.tier-del');
|
||||
var add = ev.target.closest('.tier-add');
|
||||
if (del) {
|
||||
var rows = tierRows(editor);
|
||||
if (rows.length > 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 '<tr' + (changed ? ' class="is-changed"' : '') + '><th scope="row">' + row[0] + '</th><td>' + row[2](va) + '</td><td' + cls + '>' +
|
||||
(b ? row[2](vb) : '<span class="muted">isian belum valid</span>') + '</td></tr>';
|
||||
}).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();
|
||||
}
|
||||
})();
|
||||
@@ -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 '<div' + (cls ? ' class="' + cls + '"' : '') + '><dt>' + label + '</dt><dd>' + value + '</dd></div>';
|
||||
}
|
||||
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 = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg><span>' + text + '</span>';
|
||||
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 ? '<span class="cmp-tag">Pembanding</span>' : (best ? '<span class="cmp-tag">Tertinggi</span>' : '');
|
||||
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 = '<p class="cmp-diff ' + (d >= 0 ? 'up' : 'down') + '">' + (d >= 0 ? '+' : '−') + F.rp(Math.abs(d)).replace('−', '') +
|
||||
(d >= 0 ? ' lebih besar' : ' lebih kecil') + ' dari deposito</p>';
|
||||
}
|
||||
return '<li class="' + cls + '">' +
|
||||
'<div class="cmp-top"><span class="cmp-name">' + esc(c.name) + tag + '</span><span class="cmp-total">' + F.rp(c.total) + '</span></div>' +
|
||||
'<div class="cmp-bar" role="img" aria-label="Bunga neto ' + F.rp(c.bungaNet) + (x.dep ? '' : ', cashback neto ' + F.rp(c.cashbackNet)) + '">' +
|
||||
'<i data-w="' + wBunga.toFixed(2) + '"></i>' + (x.dep ? '' : '<i data-w="' + wCb.toFixed(2) + '"></i>') + '</div>' +
|
||||
'<dl class="cmp-meta">' +
|
||||
'<div><dt>Rate</dt><dd>' + F.pct(c.rate) + '</dd></div>' +
|
||||
'<div><dt>Bunga neto</dt><dd>' + F.rp(c.bungaNet) + '</dd></div>' +
|
||||
(x.dep ? '<div><dt>Cashback</dt><dd>—</dd></div>' : '<div><dt>Cashback</dt><dd>' + F.rp(c.cashbackNet) + '</dd></div>') +
|
||||
'<div><dt>Total rate</dt><dd>' + F.pct(c.totalRate) + '</dd></div>' +
|
||||
'</dl>' + diff + '</li>';
|
||||
}).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', '<p class="legend" id="cmpLegend"><span><i></i>Bunga neto</span><span><i class="cb"></i>Cashback neto</span></p>');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCalcLines(r) {
|
||||
var s = full.settings, f = r.fund, t = r.input.tenor;
|
||||
var html = '<div class="calc-block"><h4>Dana kelolaan</h4><dl class="lines">' +
|
||||
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') + '</dl></div>';
|
||||
|
||||
html += '<div class="calc-block"><h4>Pendapatan bank selama ' + t + ' bulan</h4><dl class="lines">' +
|
||||
line('PUAB<span class="formula">' + F.pct(s.porsi_puab).replace(',00', '') + ' dana × ' + F.pct(s.puab) + ' × ' + s.hari_sebulan + '/' + s.hari_setahun + ' × ' + t + '</span>', F.rp(f.puabIncome)) +
|
||||
line('Kredit<span class="formula">' + F.pct(s.porsi_kredit).replace(',00', '') + ' dana × ' + F.pct(s.kredit) + ' × ' + t + '/12</span>', F.rp(f.creditIncome)) +
|
||||
line('Total pendapatan', F.rp(f.income), 'is-strong') + '</dl></div>';
|
||||
|
||||
PRODUCT_KEYS.forEach(function (key) {
|
||||
var pr = r.products[key];
|
||||
html += '<div class="calc-block"><h4>Spread ' + esc(pr.name) + ' (' + F.pct(pr.rate) + ')</h4><dl class="lines">' +
|
||||
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)) + '</dl></div>';
|
||||
});
|
||||
|
||||
html += '<div class="calc-block"><h4>Cashback</h4><dl class="lines">' +
|
||||
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', '')) + '</dl></div>';
|
||||
|
||||
html += '<div class="calc-block"><h4>Deposito pembanding</h4><dl class="lines">' +
|
||||
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)) + '</dl></div>';
|
||||
$('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 */ }); });
|
||||
}
|
||||
})();
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 });
|
||||
})();
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// Calculation endpoint for the public calculator: GET ?n=<nominal>&t=<tenor>&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));
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
// Application configuration. No credentials live in this file: they come from
|
||||
// environment variables or from a secrets file. Template: database/secrets.example.php.
|
||||
// Looked up in this order:
|
||||
// 1. SERUBI_SECRETS_FILE (environment variable)
|
||||
// 2. <parent of the app folder>/.serubi/secrets.php (outside the document root, preferred)
|
||||
// 3. <app folder>/.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'] ?? ''),
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
// Default parameters, taken from sheet COB of
|
||||
// CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx (columns T:V).
|
||||
// Used only to seed an empty database; afterwards the admin page is the source.
|
||||
|
||||
return [
|
||||
'settings' => [
|
||||
// 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],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
// CLI helper (never reachable over HTTP).
|
||||
// php database/migrate.php create database, tables and seed (if empty)
|
||||
// php database/migrate.php --reseed reset all parameters to the workbook defaults
|
||||
// php database/migrate.php --dump-params print the active parameters as JSON
|
||||
// php database/migrate.php --create-admin create an admin account interactively
|
||||
// php database/migrate.php --create-db-user create a least-privilege MySQL user for the app
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
require __DIR__ . '/../includes/auth.php';
|
||||
|
||||
function ask(string $label, bool $secret = false): string
|
||||
{
|
||||
fwrite(STDOUT, $label . ': ');
|
||||
if ($secret && DIRECTORY_SEPARATOR === '/') {
|
||||
shell_exec('stty -echo');
|
||||
$v = (string)fgets(STDIN);
|
||||
shell_exec('stty echo');
|
||||
fwrite(STDOUT, "\n");
|
||||
} else {
|
||||
$v = (string)fgets(STDIN);
|
||||
}
|
||||
return rtrim($v, "\r\n");
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
} catch (PDOException $e) {
|
||||
if ((int)($e->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']));
|
||||
@@ -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;
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
// Template for the secrets file read by config.php (chmod 600). Locations, in order:
|
||||
// /home/<akun>/.serubi/secrets.php outside the document root (preferred)
|
||||
// <app folder>/.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/<akun>/.serubi/cache',
|
||||
];
|
||||
@@ -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!%';
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
ini_set('display_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
echo 'PHP ' . PHP_VERSION . '<br>';
|
||||
echo 'PDO: ' . (class_exists('PDO') ? 'ya' : 'TIDAK ADA') . '<br>';
|
||||
echo 'pdo_mysql: ' . (extension_loaded('pdo_mysql') ? 'ya' : 'TIDAK ADA') . '<br>';
|
||||
echo 'config.php: ' . (is_file(__DIR__ . '/config.php') ? 'ada' : 'TIDAK ADA di ' . __DIR__) . '<br><hr>';
|
||||
|
||||
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<br>";
|
||||
} catch (Throwable $e) {
|
||||
echo "$h : " . htmlspecialchars($e->getMessage()) . '<br>';
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
echo 'FATAL: ' . htmlspecialchars(get_class($e) . ': ' . $e->getMessage()
|
||||
. ' di ' . $e->getFile() . ':' . $e->getLine());
|
||||
}
|
||||
@@ -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://<server>/config.php`; hasilnya harus 403/404.
|
||||
|
||||
## Cek cepat setelah deploy
|
||||
|
||||
```
|
||||
curl -sI https://<server>/ | grep -iE "server|x-powered|content-security|strict-transport"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://<server>/config.php # 403
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -X TRACE https://<server>/ # 403/405
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://<server>/.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/<akun>/.serubi/` bila ada akses, atau di `<folder aplikasi>/.serubi/` (folder ini sudah berisi `.htaccess` yang menolak akses HTTP). Cek: `https://<server>/.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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,498 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
|
||||
/*
|
||||
* POST handlers for the admin page. Each returns null on success (after a
|
||||
* flash + redirect) or an array ['errors' => [...], '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'));
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
require_once __DIR__ . '/totp.php';
|
||||
|
||||
const LOGIN_MAX_FAILS = 5; // per IP within the window: the IP is locked out
|
||||
const LOGIN_MAX_FAILS_USER = 10; // per username within the window: audited + shown to the owner (no lockout)
|
||||
const LOGIN_WINDOW_MIN = 15;
|
||||
const PRE_AUTH_TTL = 300; // seconds between a correct password and the OTP step
|
||||
const SESSION_IDLE_MIN = 30;
|
||||
const SESSION_ABSOLUTE_HOURS = 8;
|
||||
const SESSION_ROTATE_MIN = 15;
|
||||
const PASSWORD_MIN = 10;
|
||||
|
||||
/** Short fingerprint of the password hash; a password change invalidates every other session. */
|
||||
function password_fingerprint(string $hash): string
|
||||
{
|
||||
return substr(hash('sha256', $hash), 0, 16);
|
||||
}
|
||||
|
||||
function current_admin(): ?array
|
||||
{
|
||||
start_session();
|
||||
$a = $_SESSION['admin'] ?? null;
|
||||
if (!$a) {
|
||||
return null;
|
||||
}
|
||||
$now = time();
|
||||
if ($now - ($_SESSION['seen'] ?? 0) > 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;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Self-hosted CAPTCHA for the admin login: the image is drawn by the server
|
||||
* (GD), so no third-party service is called and the Content-Security-Policy
|
||||
* stays strict. The answer lives only in the session, as a hash, and is valid
|
||||
* once, for CAPTCHA_TTL seconds.
|
||||
*/
|
||||
|
||||
const CAPTCHA_TTL = 300;
|
||||
const CAPTCHA_LENGTH = 5;
|
||||
const CAPTCHA_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I/L
|
||||
|
||||
function captcha_available(): bool
|
||||
{
|
||||
return function_exists('imagecreatetruecolor') && function_exists('imagepng');
|
||||
}
|
||||
|
||||
/** Starts a new challenge (called when the login form is rendered). */
|
||||
function captcha_new(): void
|
||||
{
|
||||
start_session();
|
||||
if (!captcha_available()) {
|
||||
// Without GD: a text question, weaker than the image, so login never becomes impossible.
|
||||
error_log('[serubi] Ekstensi GD tidak tersedia: CAPTCHA memakai soal teks.');
|
||||
$a = random_int(2, 9);
|
||||
$b = random_int(2, 9);
|
||||
$_SESSION['captcha'] = ['code' => (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);
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function app_config(): array
|
||||
{
|
||||
static $config = null;
|
||||
if ($config === null) {
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
date_default_timezone_set($config['app']['timezone']);
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared PDO connection. Web requests only connect: the schema is created and
|
||||
* upgraded explicitly (database/migrate.php or the SQL files in database/),
|
||||
* never on each request.
|
||||
*/
|
||||
function db(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
if ($pdo instanceof PDO) {
|
||||
return $pdo;
|
||||
}
|
||||
$c = app_config()['db'];
|
||||
if ($c['user'] === '') {
|
||||
throw new RuntimeException('Kredensial database belum dikonfigurasi (lihat database/secrets.example.php).');
|
||||
}
|
||||
$pdo = db_connect($c, true);
|
||||
$pdo->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* SERUBI cashback engine, server side. Line-by-line port of assets/js/engine.js
|
||||
* (sheet "COB" and "CALCULATOR CASHBACK SERUBI" of the reference workbook), so
|
||||
* the public calculator gets results without receiving the bank's internal
|
||||
* parameters. Both engines are validated against tests/excel-fixtures.json.
|
||||
*/
|
||||
|
||||
const ENGINE_PRODUCTS = ['giro' => 'Giro Umum', 'simanja' => 'Tabungan SIMANJA', 'simpeda' => 'Tabungan SIMPEDA'];
|
||||
|
||||
/** Excel ROUNDDOWN(x, 0) on 15 significant digits (same as Number(x.toPrecision(15)) in JS). */
|
||||
function engine_rounddown(float $x): float
|
||||
{
|
||||
$v = (float)sprintf('%.14e', $x);
|
||||
return $v < 0 ? ceil($v) : floor($v);
|
||||
}
|
||||
|
||||
/** Tiers [{max: float|null, rate}], ascending; first tier with n <= max wins, null max = "di atas". */
|
||||
function engine_tier_rate(?array $tiers, float $n): float
|
||||
{
|
||||
foreach ($tiers ?? [] as $t) {
|
||||
if (!isset($t['max']) || $n <= $t['max']) {
|
||||
return (float)($t['rate'] ?? 0);
|
||||
}
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/** Cashback sharing [{min, nasabah}]: highest min that n reaches wins (COB!I34:I35). */
|
||||
function engine_share(array $params, float $n, int $tenor): array
|
||||
{
|
||||
if (!in_array($tenor, $params['cashbackTenors'] ?? [], true)) {
|
||||
return ['share' => null, 'reason' => 'tenor'];
|
||||
}
|
||||
$tiers = $params['cashbackTiers'] ?? [];
|
||||
usort($tiers, fn($a, $b) => $b['min'] <=> $a['min']);
|
||||
foreach ($tiers as $t) {
|
||||
if ($n >= $t['min']) {
|
||||
return ['share' => (float)$t['nasabah'], 'reason' => null];
|
||||
}
|
||||
}
|
||||
return ['share' => null, 'reason' => 'nominal'];
|
||||
}
|
||||
|
||||
function engine_min_cashback_nominal(array $params): float
|
||||
{
|
||||
$mins = array_column($params['cashbackTiers'] ?? [], 'min');
|
||||
return $mins ? (float)min($mins) : 0.0;
|
||||
}
|
||||
|
||||
function engine_deposito_rate(array $params, float $n, int $tenor): float
|
||||
{
|
||||
if ($n < $params['settings']['deposito_min']) { // COB!K14: F4<1000000 -> 0
|
||||
return 0.0;
|
||||
}
|
||||
return engine_tier_rate($params['depositoRates'][$tenor] ?? null, $n);
|
||||
}
|
||||
|
||||
function engine_calculate(array $params, float $nominal, int $tenor, string $customerType): array
|
||||
{
|
||||
$p = $params['settings'];
|
||||
$n = max(0.0, $nominal);
|
||||
$t = $tenor;
|
||||
$customerType = $customerType === 'badan' ? 'badan' : 'perorangan';
|
||||
$year = $p['hari_setahun'];
|
||||
$month = $p['hari_sebulan'];
|
||||
|
||||
// Pengelolaan dana (COB!F23:F38)
|
||||
$gwm = $n * $p['gwm'];
|
||||
$kas = $n * $p['kas'];
|
||||
$managed = $n - ($gwm + $kas);
|
||||
$puabBase = $managed * $p['porsi_puab'];
|
||||
$puabIncome = $puabBase * $p['puab'] * ($month / $year) * $t;
|
||||
$creditBase = $managed * $p['porsi_kredit'];
|
||||
$creditIncome = $creditBase * $p['kredit'] * ($t / 12);
|
||||
$income = $puabIncome + $creditIncome;
|
||||
|
||||
$sh = engine_share($params, $n, $t);
|
||||
$eligible = $sh['share'] !== null;
|
||||
$share = $eligible ? $sh['share'] : 0.0;
|
||||
|
||||
// Biaya & spread per produk (COB!H22:R37)
|
||||
$lps = min($n, $p['lps_batas']) * $p['premi_lps'];
|
||||
$ohc = $puabIncome * $p['ohc'];
|
||||
$products = [];
|
||||
foreach (ENGINE_PRODUCTS as $key => $name) {
|
||||
$rate = engine_tier_rate($params['productRates'][$key] ?? null, $n);
|
||||
$bunga = engine_rounddown((($n * $rate * $month) / $year) * $t);
|
||||
$program = $key === 'simanja' ? $p['biaya_program'] : 0.0;
|
||||
$cost = $bunga + $program + $ohc + $lps;
|
||||
$spread = $income - $cost;
|
||||
$bungaTax = $bunga * $p['pajak_tabungan'];
|
||||
$products[$key] = [
|
||||
'key' => $key, 'name' => $name, 'rate' => $rate,
|
||||
'bungaGross' => $bunga, 'bungaTax' => $bungaTax, 'bungaNet' => $bunga - $bungaTax,
|
||||
'program' => $program, 'ohc' => $ohc, 'lps' => $lps, 'cost' => $cost, 'spread' => $spread,
|
||||
'cashbackGross' => $spread * $share,
|
||||
'costOfFund' => $managed > 0 ? ($cost + $spread * $share) / $managed : 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
// Cashback = rata-rata 3 produk (COB!H7:H10)
|
||||
$gross = $eligible ? ($products['giro']['cashbackGross'] + $products['simanja']['cashbackGross'] + $products['simpeda']['cashbackGross']) / 3 : 0.0;
|
||||
$negative = $gross < 0;
|
||||
if ($negative) {
|
||||
$gross = 0.0;
|
||||
}
|
||||
$taxRate = $customerType === 'badan' ? $p['pajak_cashback_badan'] : $p['pajak_cashback_perorangan'];
|
||||
$tax = $gross * $taxRate;
|
||||
$net = $gross - $tax;
|
||||
$equiv = $n > 0 && $t > 0 ? ($net / $n) * ($year / ($month * $t)) : 0.0;
|
||||
|
||||
// Deposito pembanding (COB!K14:L17)
|
||||
$dRate = engine_deposito_rate($params, $n, $t);
|
||||
$dGross = engine_rounddown((($n * $dRate * $month) / $year) * $t);
|
||||
$dTax = $dGross * $p['pajak_deposito'];
|
||||
$dNet = $dGross - $dTax;
|
||||
$deposito = ['rate' => $dRate, 'gross' => $dGross, 'tax' => $dTax, 'net' => $dNet, 'monthly' => $t ? $dNet / $t : 0.0];
|
||||
|
||||
// Perbandingan (CALC!A10:F13)
|
||||
$comparison = [];
|
||||
foreach (ENGINE_PRODUCTS as $key => $name) {
|
||||
$pr = $products[$key];
|
||||
$total = $pr['bungaNet'] + $net;
|
||||
$comparison[] = [
|
||||
'key' => $key, 'name' => $name, 'rate' => $pr['rate'], 'bungaNet' => $pr['bungaNet'],
|
||||
'cashbackNet' => $net, 'total' => $total, 'totalRate' => $pr['rate'] + $equiv, 'diff' => $total - $dNet,
|
||||
];
|
||||
}
|
||||
$best = array_reduce($comparison, fn($a, $b) => $a === null || $b['total'] > $a['total'] ? $b : $a);
|
||||
|
||||
// Pinalti pencairan sebelum jatuh tempo (CALC!A17:B22)
|
||||
$penalty = ['refund' => $net, 'tax' => $gross * $taxRate, 'fee' => $net * $p['pinalti']];
|
||||
$penalty['total'] = $penalty['refund'] + $penalty['tax'] + $penalty['fee'];
|
||||
|
||||
return [
|
||||
'input' => ['nominal' => $n, 'tenor' => $t, 'customerType' => $customerType],
|
||||
'eligible' => $eligible && !$negative,
|
||||
'reason' => $negative ? 'spread' : $sh['reason'],
|
||||
'minNominal' => engine_min_cashback_nominal($params),
|
||||
'share' => $share, 'shareBank' => $eligible ? 1 - $share : 0.0,
|
||||
'fund' => [
|
||||
'gwm' => $gwm, 'kas' => $kas, 'managed' => $managed, 'puabBase' => $puabBase, 'puabIncome' => $puabIncome,
|
||||
'creditBase' => $creditBase, 'creditIncome' => $creditIncome, 'income' => $income,
|
||||
],
|
||||
'products' => $products,
|
||||
'cashback' => ['gross' => $gross, 'taxRate' => $taxRate, 'tax' => $tax, 'net' => $net, 'monthly' => $t ? $net / $t : 0.0, 'equiv' => $equiv],
|
||||
'deposito' => $deposito,
|
||||
'comparison' => $comparison,
|
||||
'best' => $best,
|
||||
'penalty' => $penalty,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a visitor without an admin session may see: the customer-facing result
|
||||
* only. Cost structure, spread, sharing tiers and the deposito comparison stay
|
||||
* on the server.
|
||||
*/
|
||||
function engine_public_result(array $r, array $params): array
|
||||
{
|
||||
return [
|
||||
'input' => $r['input'],
|
||||
'eligible' => $r['eligible'],
|
||||
'reason' => $r['reason'],
|
||||
'minNominal' => $r['minNominal'],
|
||||
'share' => $r['share'],
|
||||
'cashback' => $r['cashback'],
|
||||
'comparison' => array_map(fn($c) => array_diff_key($c, ['diff' => 1]), $r['comparison']),
|
||||
'penalty' => $r['penalty'] + ['rate' => $params['settings']['pinalti']],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model for the public in-browser engine (assets/js/engine-public.js), so the
|
||||
* calculator answers instantly and offline. The bank's cost structure is folded
|
||||
* into one margin per tenor:
|
||||
*
|
||||
* pendapatan PUAB − OHC + pendapatan kredit = nominal × margin[tenor]
|
||||
* margin = (1 − GWM − kas) × (porsi PUAB × rate PUAB × hari sebulan/hari setahun × tenor × (1 − OHC)
|
||||
* + porsi kredit × bunga kredit × tenor/12)
|
||||
*
|
||||
* GWM, kas, porsi PUAB/kredit, rate PUAB, bunga kredit, OHC and the deposito
|
||||
* rates are not sent. Everything else is customer-facing (product rates, taxes,
|
||||
* penalty, LPS rules) or, like the sharing tiers, can be read off the results.
|
||||
*/
|
||||
function engine_client_model(array $params): array
|
||||
{
|
||||
$p = $params['settings'];
|
||||
$year = $p['hari_setahun'];
|
||||
$month = $p['hari_sebulan'];
|
||||
$margin = [];
|
||||
foreach ($params['tenors'] as $t) {
|
||||
$margin[(string)$t] = (1 - ($p['gwm'] + $p['kas']))
|
||||
* ($p['porsi_puab'] * $p['puab'] * ($month / $year) * $t * (1 - $p['ohc'])
|
||||
+ $p['porsi_kredit'] * $p['kredit'] * ($t / 12));
|
||||
}
|
||||
return [
|
||||
'tenors' => $params['tenors'],
|
||||
'cashbackTenors' => $params['cashbackTenors'],
|
||||
'minNominal' => engine_min_cashback_nominal($params),
|
||||
'days' => ['month' => $month, 'year' => $year],
|
||||
'margin' => $margin,
|
||||
'programAvg' => $p['biaya_program'] / 3, // SIMANJA programme fee, averaged over the 3 products
|
||||
'lps' => ['rate' => $p['premi_lps'], 'cap' => $p['lps_batas']],
|
||||
'productRates' => $params['productRates'],
|
||||
'cashbackTiers' => $params['cashbackTiers'],
|
||||
'tax' => ['bunga' => $p['pajak_tabungan'], 'perorangan' => $p['pajak_cashback_perorangan'], 'badan' => $p['pajak_cashback_badan']],
|
||||
'pinalti' => $p['pinalti'],
|
||||
'paramsAt' => $params['updatedAt'],
|
||||
'generatedAt' => date('c'),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/security.php';
|
||||
require_once __DIR__ . '/params.php';
|
||||
|
||||
security_bootstrap();
|
||||
|
||||
function e($v): string
|
||||
{
|
||||
return htmlspecialchars(input_str($v), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
/** Request values may be arrays when tampered with; anything non-scalar becomes ''. */
|
||||
function input_str($v): string
|
||||
{
|
||||
return is_scalar($v) ? (string)$v : '';
|
||||
}
|
||||
|
||||
/** Root-relative URL of the app, so it works under /calcserubi/ or a vhost root. */
|
||||
function base_url(string $path = ''): string
|
||||
{
|
||||
static $base = null;
|
||||
if ($base === null) {
|
||||
$script = str_replace('\\', '/', $_SERVER['SCRIPT_NAME'] ?? '/');
|
||||
$appRoot = str_replace('\\', '/', realpath(__DIR__ . '/..'));
|
||||
$file = str_replace('\\', '/', realpath($_SERVER['SCRIPT_FILENAME'] ?? '') ?: '');
|
||||
$rel = $file && strpos($file, $appRoot) === 0 ? substr($file, strlen($appRoot)) : '';
|
||||
$base = rtrim($rel ? substr($script, 0, strlen($script) - strlen($rel)) : dirname($script), '/') . '/';
|
||||
}
|
||||
return $base . ltrim($path, '/');
|
||||
}
|
||||
|
||||
function app_version(): string
|
||||
{
|
||||
return (string)(app_config()['app']['version'] ?? '1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Versioned asset URL: ?v=<app version>-<file mtime>. 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 '<input type="hidden" name="csrf" value="' . e(csrf_token()) . '">';
|
||||
}
|
||||
|
||||
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' => '<path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>',
|
||||
'copy' => '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
'reset' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
|
||||
'info' => '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
||||
'alert' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
'check' => '<path d="M20 6 9 17l-5-5"/>',
|
||||
'lock' => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'logout' => '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
|
||||
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
'trash' => '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
|
||||
'users' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
'history' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
||||
'sliders' => '<path d="M21 4h-7"/><path d="M10 4H3"/><path d="M21 12h-9"/><path d="M8 12H3"/><path d="M21 20h-5"/><path d="M12 20H3"/><path d="M14 2v4"/><path d="M8 10v4"/><path d="M16 18v4"/>',
|
||||
'wallet' => '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
|
||||
'grid' => '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>',
|
||||
'pie' => '<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>',
|
||||
'calc' => '<rect width="16" height="20" x="4" y="2" rx="2"/><path d="M8 6h8"/><path d="M16 14v4"/><path d="M16 10h.01"/><path d="M12 10h.01"/><path d="M8 10h.01"/><path d="M12 14h.01"/><path d="M8 14h.01"/><path d="M12 18h.01"/><path d="M8 18h.01"/>',
|
||||
'sun' => '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
|
||||
'moon' => '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
'chevron' => '<path d="m6 9 6 6 6-6"/>',
|
||||
'save' => '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/>',
|
||||
'key' => '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
|
||||
'external' => '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3"/>',
|
||||
'menu' => '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
|
||||
'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
];
|
||||
return '<svg class="' . e($class) . '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' . ($paths[$name] ?? '') . '</svg>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 '<img class="logo ' . $cls . '" src="' . e(asset('assets/img/' . $file)) . '" alt="" width="589" height="120" decoding="async">';
|
||||
};
|
||||
$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 '<span class="logo-wrap logo-' . e($variant) . ($class ? ' ' . e($class) : '') . '">' . $html . '</span>';
|
||||
}
|
||||
|
||||
/** Inline script that applies the saved theme before first paint. */
|
||||
function theme_boot_script(): string
|
||||
{
|
||||
return '<script nonce="' . csp_nonce() . '">' . "try{var t=localStorage.getItem('serubi-theme');if(t==='light'||t==='dark')document.documentElement.dataset.theme=t}catch(e){}</script>";
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
const SERUBI_PRODUCTS = ['giro' => '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,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Security baseline, loaded first by every web entry point (via helpers.php):
|
||||
* error handling, request-method guard, security headers, HTTPS detection,
|
||||
* and hardened session settings.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
function security_config(): array
|
||||
{
|
||||
return (app_config()['security'] ?? []) + [
|
||||
'force_https' => 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 '<!doctype html><meta charset="utf-8"><title>Terjadi kesalahan</title><p>Terjadi kesalahan pada server. Silakan coba lagi beberapa saat lagi.</p>';
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Two-factor authentication for admins: TOTP (RFC 6238, SHA-1, 6 digits, 30 s),
|
||||
* compatible with Google/Microsoft Authenticator. The shared secret is stored
|
||||
* encrypted (AES-256-GCM) with security.app_key, which lives outside the
|
||||
* document root. Recovery codes are stored as HMACs keyed with the same key.
|
||||
*/
|
||||
|
||||
const TOTP_PERIOD = 30;
|
||||
const TOTP_DIGITS = 6;
|
||||
const TOTP_WINDOW = 1; // accept one step before/after for clock drift
|
||||
const OTP_MAX_FAILS = 5; // wrong codes within LOGIN_WINDOW_MIN before the login is dropped
|
||||
const RECOVERY_CODE_COUNT = 10;
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
function app_key(): string
|
||||
{
|
||||
$key = base64_decode((string)(security_config()['app_key'] ?? ''), true);
|
||||
if ($key === false || strlen($key) !== 32) {
|
||||
throw new RuntimeException('security.app_key belum diisi (32 byte base64); dibutuhkan untuk OTP.');
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
function base32_encode(string $bin): string
|
||||
{
|
||||
$bits = '';
|
||||
foreach (str_split($bin) as $ch) {
|
||||
$bits .= str_pad(decbin(ord($ch)), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$out = '';
|
||||
foreach (str_split($bits, 5) as $chunk) {
|
||||
$out .= BASE32_ALPHABET[bindec(str_pad($chunk, 5, '0'))];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function base32_decode(string $b32): string
|
||||
{
|
||||
$bits = '';
|
||||
foreach (str_split(strtoupper(preg_replace('/[^A-Za-z2-7]/', '', $b32))) as $ch) {
|
||||
$bits .= str_pad(decbin(strpos(BASE32_ALPHABET, $ch)), 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$out = '';
|
||||
foreach (str_split($bits, 8) as $byte) {
|
||||
if (strlen($byte) === 8) {
|
||||
$out .= chr(bindec($byte));
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function totp_new_secret(): string
|
||||
{
|
||||
return base32_encode(random_bytes(20)); // 160-bit, as recommended by RFC 4226
|
||||
}
|
||||
|
||||
function totp_code(string $secret, int $step): string
|
||||
{
|
||||
$hash = hash_hmac('sha1', pack('J', $step), base32_decode($secret), true);
|
||||
$offset = ord($hash[19]) & 0x0f;
|
||||
$bin = ((ord($hash[$offset]) & 0x7f) << 24) | (ord($hash[$offset + 1]) << 16)
|
||||
| (ord($hash[$offset + 2]) << 8) | ord($hash[$offset + 3]);
|
||||
return str_pad((string)($bin % (10 ** TOTP_DIGITS)), TOTP_DIGITS, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/** The matching time step, or null. Steps at or before $lastStep are refused (no replay). */
|
||||
function totp_verify(string $secret, string $code, ?int $lastStep): ?int
|
||||
{
|
||||
$code = preg_replace('/\D/', '', $code);
|
||||
if (strlen($code) !== TOTP_DIGITS) {
|
||||
return null;
|
||||
}
|
||||
$now = intdiv(time(), TOTP_PERIOD);
|
||||
for ($s = $now - TOTP_WINDOW; $s <= $now + TOTP_WINDOW; $s++) {
|
||||
if (($lastStep === null || $s > $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)];
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/includes/auth.php';
|
||||
require __DIR__ . '/includes/engine.php';
|
||||
|
||||
// The calculation breakdown is internal: it is only rendered for a logged-in admin.
|
||||
try {
|
||||
$admin = viewing_admin();
|
||||
} catch (Throwable $ex) {
|
||||
$admin = null;
|
||||
}
|
||||
send_security_headers($admin !== null); // admin view is never cached
|
||||
|
||||
try {
|
||||
$params = load_params();
|
||||
$dbError = null;
|
||||
} catch (Throwable $ex) {
|
||||
$params = null;
|
||||
$dbError = $ex->getMessage();
|
||||
}
|
||||
$tenors = $params['tenors'] ?? [1, 3, 6, 12, 24];
|
||||
$cbTenors = $params['cashbackTenors'] ?? [];
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#0b2e66">
|
||||
<title>Kalkulator Cashback SERUBI · Bank Papua</title>
|
||||
<?= theme_boot_script() ?>
|
||||
<link rel="manifest" href="<?= e(base_url('manifest.webmanifest')) ?>">
|
||||
<link rel="icon" href="<?= e(asset('assets/favicon.svg')) ?>" type="image/svg+xml">
|
||||
<link rel="preload" href="<?= e(base_url('assets/fonts/archivo-var.woff2')) ?>" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="<?= e(asset('assets/css/base.css')) ?>">
|
||||
<link rel="stylesheet" href="<?= e(asset('assets/css/app.css')) ?>">
|
||||
<meta name="app-version" content="<?= e(app_version()) ?>">
|
||||
</head>
|
||||
<body class="page-calc">
|
||||
<a class="skip-link" href="#hasil">Lewati ke hasil simulasi</a>
|
||||
|
||||
<header class="topbar">
|
||||
<a class="brand" href="<?= e(base_url()) ?>" aria-label="Bank Papua SERUBI, beranda kalkulator">
|
||||
<?= bank_logo('auto') ?>
|
||||
<span class="brand-prog">SERUBI</span>
|
||||
</a>
|
||||
<div class="topbar-actions">
|
||||
<button type="button" class="icon-btn" data-theme-toggle aria-label="Ganti tema terang/gelap">
|
||||
<?= icon('moon', 'icon icon-moon') ?><?= icon('sun', 'icon icon-sun') ?>
|
||||
</button>
|
||||
<a class="text-btn" href="<?= e(base_url('admin/')) ?>"><?= icon('lock') ?><span>Admin</span></a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<?php if ($dbError): ?>
|
||||
<main class="desk desk-error">
|
||||
<div class="panel-error" role="alert">
|
||||
<?= icon('alert') ?>
|
||||
<div>
|
||||
<h1>Parameter tidak dapat dimuat</h1>
|
||||
<p>Kalkulator membutuhkan database MySQL. Pastikan MySQL berjalan di port yang diatur pada <code>config.php</code>, lalu muat ulang halaman.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<?php else: ?>
|
||||
<main class="desk">
|
||||
<section class="machine" aria-labelledby="machine-title">
|
||||
<div class="machine-head">
|
||||
<h1 id="machine-title">Kalkulator Cashback</h1>
|
||||
<p>Tabungan/Giro + cashback dibandingkan deposito</p>
|
||||
</div>
|
||||
|
||||
<div class="display" id="hasil" tabindex="-1">
|
||||
<div class="display-top">
|
||||
<span class="display-label">Cashback neto</span>
|
||||
<span class="display-flag" id="dFlag"></span>
|
||||
</div>
|
||||
<output class="display-value" id="dNet" for="nominal tenor-group cust-group" aria-live="polite">Rp0</output>
|
||||
<dl class="display-sub">
|
||||
<div><dt>Bruto</dt><dd id="dGross">Rp0</dd></div>
|
||||
<div><dt>Setara</dt><dd id="dEquiv">0,00% p.a.</dd></div>
|
||||
<div><dt>Per bulan</dt><dd id="dMonthly">Rp0</dd></div>
|
||||
</dl>
|
||||
<p class="display-note" id="dNote" hidden></p>
|
||||
</div>
|
||||
|
||||
<form class="keys" id="calcForm" novalidate>
|
||||
<div class="field">
|
||||
<label for="nominal">Nominal penempatan</label>
|
||||
<div class="entry">
|
||||
<span class="entry-prefix" aria-hidden="true">Rp</span>
|
||||
<input id="nominal" name="nominal" type="text" inputmode="numeric" autocomplete="off" enterkeyhint="done"
|
||||
value="55.000.000" aria-describedby="nominalHelp nominalErr" spellcheck="false">
|
||||
</div>
|
||||
<p class="help" id="nominalHelp">Cashback berlaku mulai <span id="minNominal">Rp50.000.000</span></p>
|
||||
<p class="field-error" id="nominalErr" hidden></p>
|
||||
<div class="quick" role="group" aria-label="Pilih nominal cepat">
|
||||
<?php foreach ([50e6 => '50 jt', 100e6 => '100 jt', 250e6 => '250 jt', 500e6 => '500 jt', 1e9 => '1 M', 2e9 => '2 M'] as $v => $lbl): ?>
|
||||
<button type="button" class="chip" data-amount="<?= (int)$v ?>"><?= e($lbl) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="field" id="tenor-group">
|
||||
<legend>Jangka waktu</legend>
|
||||
<div class="segmented seg-tenor">
|
||||
<?php foreach ($tenors as $t): $noCb = !in_array($t, $cbTenors, true); ?>
|
||||
<label class="seg">
|
||||
<input type="radio" name="tenor" value="<?= (int)$t ?>" <?= $t === 6 ? 'checked' : '' ?>>
|
||||
<span class="seg-face">
|
||||
<span class="seg-num"><?= (int)$t ?></span>
|
||||
<span class="seg-unit">bulan</span>
|
||||
<?php if ($noCb): ?><span class="seg-tag">tanpa cashback</span><?php endif; ?>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="field" id="cust-group">
|
||||
<legend>Jenis nasabah</legend>
|
||||
<div class="segmented seg-cust">
|
||||
<label class="seg"><input type="radio" name="cust" value="perorangan" checked><span class="seg-face">Perorangan</span></label>
|
||||
<label class="seg"><input type="radio" name="cust" value="badan"><span class="seg-face">Badan usaha</span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="machine-actions">
|
||||
<button type="button" class="key key-plain" id="btnReset"><?= icon('reset') ?><span>Reset</span></button>
|
||||
<button type="button" class="key key-plain" id="btnCopy"><?= icon('copy') ?><span>Salin ringkasan</span></button>
|
||||
<button type="button" class="key key-gold" id="btnPrint"><?= icon('printer') ?><span>Cetak struk</span></button>
|
||||
</div>
|
||||
<p class="toast" id="toast" role="status" aria-live="polite"></p>
|
||||
<p class="model-note" id="modelNote" role="status" hidden></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="tape-wrap" aria-labelledby="tape-title">
|
||||
<div class="tape-slot" aria-hidden="true"></div>
|
||||
<article class="tape" id="tape">
|
||||
<header class="tape-head">
|
||||
<?= bank_logo('color', 'tape-logo') ?>
|
||||
<h2 id="tape-title">Simulasi Cashback SERUBI</h2>
|
||||
<p class="tape-meta"><time id="tDate"></time></p>
|
||||
<dl class="lines lines-input">
|
||||
<div><dt>Nominal</dt><dd id="tNominal"></dd></div>
|
||||
<div><dt>Jangka waktu</dt><dd id="tTenor"></dd></div>
|
||||
<div><dt>Jenis nasabah</dt><dd id="tCust"></dd></div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<section class="tape-sec">
|
||||
<dl class="lines lines-total">
|
||||
<div><dt>Cashback bruto</dt><dd id="tGross"></dd></div>
|
||||
<div><dt>Pajak cashback <span id="tTaxRate"></span></dt><dd id="tTax"></dd></div>
|
||||
<div class="is-strong"><dt>Cashback neto</dt><dd id="tNet"></dd></div>
|
||||
<div><dt>Setara per tahun</dt><dd id="tEquiv"></dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="tape-sec">
|
||||
<h3>Total diterima selama <span id="tTenor2"></span></h3>
|
||||
<ol class="cmp" id="cmp"></ol>
|
||||
<p class="tape-note">Total rate = rate produk + cashback neto disetahunkan.</p>
|
||||
</section>
|
||||
|
||||
<?php if ($admin): ?>
|
||||
<details class="tape-sec tape-details" id="detailCalc">
|
||||
<summary><h3>Rincian perhitungan <span class="cmp-tag">Hanya admin</span></h3><?= icon('chevron', 'icon chev') ?></summary>
|
||||
<div id="calcLines"></div>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
|
||||
<details class="tape-sec tape-details" id="detailPenalty">
|
||||
<summary><h3>Bila dicairkan sebelum jatuh tempo</h3><?= icon('chevron', 'icon chev') ?></summary>
|
||||
<dl class="lines" id="penaltyLines"></dl>
|
||||
<p class="tape-note">Nasabah mengembalikan cashback neto beserta pajaknya, ditambah pinalti dari cashback neto.</p>
|
||||
</details>
|
||||
|
||||
<footer class="tape-foot">
|
||||
<p>Parameter berlaku per <span id="tParamDate"><?= e(fmt_datetime($params['updatedAt'])) ?></span>.</p>
|
||||
<p class="tape-verify" id="tVerify" hidden></p>
|
||||
<p>Hasil simulasi bersifat estimasi, bukan dokumen resmi atau penawaran yang mengikat, dan dapat berubah sesuai ketentuan Bank Papua.</p>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="mini-display" id="miniDisplay" aria-hidden="true">
|
||||
<span class="mini-label">Cashback neto</span>
|
||||
<span class="mini-value" id="mNet">Rp0</span>
|
||||
<span class="mini-equiv" id="mEquiv"></span>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Visitors calculate in the browser (instant, also offline) from the folded
|
||||
// model: one margin per tenor instead of the bank's internal parameters.
|
||||
// Printing and copying fetch the official numbers from calc.php.
|
||||
// A logged-in admin also gets the full parameters for "Rincian perhitungan".
|
||||
$client = ['model' => engine_client_model($params), 'calcUrl' => base_url('calc.php'), 'swUrl' => base_url('sw.js')];
|
||||
if ($admin) {
|
||||
$client['full'] = $params;
|
||||
}
|
||||
?>
|
||||
<script nonce="<?= e(csp_nonce()) ?>">window.SERUBI_PUBLIC = <?= json_encode($client, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION) ?>;</script>
|
||||
<script src="<?= e(asset('assets/js/engine-public.js')) ?>"></script>
|
||||
<?php if ($admin): ?><script src="<?= e(asset('assets/js/engine.js')) ?>"></script><?php endif; ?>
|
||||
<script src="<?= e(asset('assets/js/format.js')) ?>"></script>
|
||||
<script src="<?= e(asset('assets/js/app.js')) ?>"></script>
|
||||
<?php endif; ?>
|
||||
<script src="<?= e(asset('assets/js/theme.js')) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,71 @@
|
||||
# Recalculates the official branch workbook with Microsoft Excel for a grid of
|
||||
# nominal x tenor inputs and writes the results to tests/excel-fixtures.json.
|
||||
# Usage: powershell -ExecutionPolicy Bypass -File tests\export-excel-fixtures.ps1 [-Workbook <path>]
|
||||
param(
|
||||
[string]$Workbook = "$env:USERPROFILE\Downloads\CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx",
|
||||
[string]$Out = (Join-Path $PSScriptRoot 'excel-fixtures.json')
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Work on a temp copy so the original file is never touched.
|
||||
$tmp = Join-Path $env:TEMP ("serubi-" + [guid]::NewGuid().ToString() + ".xlsx")
|
||||
Copy-Item -LiteralPath $Workbook -Destination $tmp
|
||||
|
||||
$nominals = @(100000, 500000, 999999, 1000000, 10000000, 49999999, 50000000, 55000000, 99999999,
|
||||
100000000, 100000001, 150000000, 250000000, 250000001, 300000000, 499999999, 500000000,
|
||||
500000001, 750000000, 999999999, 1000000000, 1000000001, 1500000000, 1999999999,
|
||||
2000000000, 2000000001, 5000000000, 10000000000, 50000000000, 50000000001, 100000000000)
|
||||
$tenors = @(1, 3, 6, 12, 24)
|
||||
|
||||
function V($cell) {
|
||||
$v = $cell.Value2
|
||||
if ($v -is [int] -and $v -lt -2000000000) { return $null } # COM error codes (#VALUE!, ...)
|
||||
if ($cell.Text -like '#*') { return $null }
|
||||
if ($v -is [string] -and $v -eq '') { return $null }
|
||||
return $v
|
||||
}
|
||||
|
||||
$xl = New-Object -ComObject Excel.Application
|
||||
$xl.Visible = $false
|
||||
$xl.DisplayAlerts = $false
|
||||
try {
|
||||
$wb = $xl.Workbooks.Open($tmp)
|
||||
$calc = $wb.Worksheets.Item('CALCULATOR CASHBACK SERUBI')
|
||||
$cob = $wb.Worksheets.Item('COB')
|
||||
$rows = @()
|
||||
foreach ($t in $tenors) {
|
||||
foreach ($n in $nominals) {
|
||||
$calc.Range('A6').Value2 = [double]$n
|
||||
$calc.Range('B6').Value2 = [double]$t
|
||||
$xl.Calculate()
|
||||
$rows += [ordered]@{
|
||||
nominal = $n; tenor = $t
|
||||
cashbackGross = V $calc.Range('C6'); cashbackNet = V $calc.Range('D6')
|
||||
giroNet = V $calc.Range('E6'); simanjaNet = V $calc.Range('F6'); simpedaNet = V $calc.Range('G6'); depositoNet = V $calc.Range('H6')
|
||||
totalGiro = V $calc.Range('B10'); totalSimanja = V $calc.Range('B11'); totalSimpeda = V $calc.Range('B12'); totalDeposito = V $calc.Range('B13')
|
||||
rateGiro = V $calc.Range('C10'); rateSimanja = V $calc.Range('C11'); rateSimpeda = V $calc.Range('C12')
|
||||
equiv = V $calc.Range('D10')
|
||||
totalRateGiro = V $calc.Range('E10'); totalRateSimanja = V $calc.Range('E11'); totalRateSimpeda = V $calc.Range('E12')
|
||||
rateDeposito = V $calc.Range('F10')
|
||||
penaltyRefund = V $calc.Range('B19'); penaltyTax = V $calc.Range('B20'); penaltyFee = V $calc.Range('B21'); penaltyTotal = V $calc.Range('B22')
|
||||
shareNasabah = V $cob.Range('I35'); shareBank = V $cob.Range('I34')
|
||||
income = V $cob.Range('F38'); puabIncome = V $cob.Range('F31'); creditIncome = V $cob.Range('F36')
|
||||
spreadSimanja = V $cob.Range('J29'); spreadSimpeda = V $cob.Range('N28'); spreadGiro = V $cob.Range('R28')
|
||||
depositoGross = V $cob.Range('L14')
|
||||
}
|
||||
}
|
||||
}
|
||||
$wb.Close($false)
|
||||
} finally {
|
||||
$xl.Quit()
|
||||
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($xl) | Out-Null
|
||||
Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$payload = [ordered]@{
|
||||
source = (Split-Path $Workbook -Leaf)
|
||||
generatedAt = (Get-Date).ToString('s')
|
||||
cases = $rows
|
||||
}
|
||||
$payload | ConvertTo-Json -Depth 5 | Out-File -FilePath $Out -Encoding utf8
|
||||
Write-Host ("Wrote {0} cases to {1}" -f $rows.Count, $Out)
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
// Validates the server-side engine (includes/engine.php) against the values
|
||||
// recalculated by Microsoft Excel (tests/excel-fixtures.json), using the
|
||||
// parameters stored in MySQL. Same checks and tolerances as tests/validate.js.
|
||||
// php tests/validate_engine.php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
require __DIR__ . '/../includes/params.php';
|
||||
require __DIR__ . '/../includes/engine.php';
|
||||
|
||||
$fixtures = json_decode(preg_replace('/^\xEF\xBB\xBF/', '', (string)file_get_contents(__DIR__ . '/excel-fixtures.json')), true);
|
||||
$params = load_params_db();
|
||||
|
||||
$rateFields = ['equiv', 'rateGiro', 'rateSimanja', 'rateSimpeda', 'rateDeposito', 'totalRateGiro', 'totalRateSimanja', 'totalRateSimpeda', 'shareNasabah', 'shareBank'];
|
||||
|
||||
function engine_view(array $r): array
|
||||
{
|
||||
$by = array_column($r['comparison'], null, 'key');
|
||||
$ok = $r['eligible'];
|
||||
$pr = $r['products'];
|
||||
return [
|
||||
'cashbackGross' => $ok ? $r['cashback']['gross'] : null, 'cashbackNet' => $ok ? $r['cashback']['net'] : null,
|
||||
'giroNet' => $pr['giro']['bungaNet'], 'simanjaNet' => $pr['simanja']['bungaNet'], 'simpedaNet' => $pr['simpeda']['bungaNet'],
|
||||
'depositoNet' => $r['deposito']['net'], 'depositoGross' => $r['deposito']['gross'], 'rateDeposito' => $r['deposito']['rate'],
|
||||
'totalGiro' => $ok ? $by['giro']['total'] : null, 'totalSimanja' => $ok ? $by['simanja']['total'] : null, 'totalSimpeda' => $ok ? $by['simpeda']['total'] : null,
|
||||
'totalDeposito' => $r['deposito']['net'],
|
||||
'rateGiro' => $pr['giro']['rate'], 'rateSimanja' => $pr['simanja']['rate'], 'rateSimpeda' => $pr['simpeda']['rate'],
|
||||
'equiv' => $ok ? $r['cashback']['equiv'] : null,
|
||||
'totalRateGiro' => $ok ? $by['giro']['totalRate'] : null, 'totalRateSimanja' => $ok ? $by['simanja']['totalRate'] : null, 'totalRateSimpeda' => $ok ? $by['simpeda']['totalRate'] : null,
|
||||
'penaltyRefund' => $ok ? $r['penalty']['refund'] : null, 'penaltyTax' => $ok ? $r['penalty']['tax'] : null,
|
||||
'penaltyFee' => $ok ? $r['penalty']['fee'] : null, 'penaltyTotal' => $ok ? $r['penalty']['total'] : null,
|
||||
'shareNasabah' => $ok ? $r['share'] : null, 'shareBank' => $ok ? $r['shareBank'] : null,
|
||||
'income' => $r['fund']['income'], 'puabIncome' => $r['fund']['puabIncome'], 'creditIncome' => $r['fund']['creditIncome'],
|
||||
'spreadSimanja' => $pr['simanja']['spread'], 'spreadSimpeda' => $pr['simpeda']['spread'], 'spreadGiro' => $pr['giro']['spread'],
|
||||
];
|
||||
}
|
||||
|
||||
$checks = 0;
|
||||
$failures = [];
|
||||
foreach ($fixtures['cases'] as $c) {
|
||||
$got = engine_view(engine_calculate($params, (float)$c['nominal'], (int)$c['tenor'], 'perorangan'));
|
||||
foreach ($c as $field => $expected) {
|
||||
if ($field === 'nominal' || $field === 'tenor' || !array_key_exists($field, $got)) {
|
||||
continue;
|
||||
}
|
||||
$checks++;
|
||||
$actual = $got[$field];
|
||||
if ($expected === null) {
|
||||
$pass = $actual === null;
|
||||
} elseif ($actual === null) {
|
||||
$pass = false;
|
||||
} elseif (in_array($field, $rateFields, true)) {
|
||||
$pass = abs($actual - $expected) < 1e-9;
|
||||
} else {
|
||||
$pass = round($actual) == round($expected) && abs($actual - $expected) < 0.01;
|
||||
}
|
||||
if (!$pass) {
|
||||
$failures[] = sprintf('%s / %d bln / %s: excel=%s app=%s', $c['nominal'], $c['tenor'], $field, var_export($expected, true), var_export($actual, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
printf("PHP engine: %d/%d checks match Excel over %d cases\n", $checks - count($failures), $checks, count($fixtures['cases']));
|
||||
foreach (array_slice($failures, 0, 20) as $f) {
|
||||
echo " FAIL $f\n";
|
||||
}
|
||||
exit($failures ? 1 : 0);
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user