[...], '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')); }