This commit is contained in:
DIGIDEV\corer committed 2026-10-20 13:16:38 +09:00
commit 3e4a056b14
67 files changed
+13926

No files matched your search

+53
View File
@@ -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],
],
];
+106
View File
@@ -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']));
+85
View File
@@ -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;
+20
View File
@@ -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',
];
+26
View File
@@ -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!%';