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