Files
calcserubi/includes/admin_actions.php
T
2026-10-20 13:16:38 +09:00

499 lines
20 KiB
PHP

<?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'));
}