6686388834
Account creation and guarded onboarding flow. Current-cash starting balance with automatic rollover enabled. Dynamic bill generation, shared/custom categories, recurring targets, and optional same-day onboarding transactions. Optional dormant reserve and additional categories. Mailgun email or authenticator-app 2FA configuration. Responsive dark/light UI with no overflow at 390px. Fixed administrator session-ID creation bug. Core implementation: [OnboardingService.php (line 59)](/Users/tyemeclifford/Documents/GH/budget/app/OnboardingService.php:59), [onboarding.php (line 19)](/Users/tyemeclifford/Documents/GH/budget/views/onboarding.php:19), and [index.php (line 143)](/Users/tyemeclifford/Documents/GH/budget/public/index.php:143). Verification passed: financial self-test, onboarding integration test, full browser walkthrough, mobile layout, routing guards, and zero browser console errors. Live email delivery requires actual Mailgun credentials and was not sent during testing.
105 lines
3.6 KiB
PHP
105 lines
3.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Budget;
|
|
|
|
use PDO;
|
|
|
|
final class Settings
|
|
{
|
|
private const DEFAULTS = [
|
|
'app.name' => 'Neon Ledger',
|
|
'app.currency_symbol' => '$',
|
|
'app.timezone' => 'America/New_York',
|
|
'theme.default' => 'dark',
|
|
'modules.income' => true,
|
|
'modules.bills' => true,
|
|
'modules.calendar' => true,
|
|
'modules.dead_account' => true,
|
|
'modules.comparisons' => true,
|
|
'modules.alerts' => true,
|
|
'modules.import_export' => true,
|
|
'dead.income_mode' => 'percent',
|
|
'dead.income_value' => 0,
|
|
'dead.transaction_mode' => 'fixed',
|
|
'dead.transaction_value' => 0,
|
|
'dead.hide_balance' => true,
|
|
'alerts.email' => '',
|
|
'alerts.windows' => [7, 3, 1],
|
|
'alerts.times' => ['09:00'],
|
|
'mailgun.api_key' => '',
|
|
'mailgun.domain' => '',
|
|
'mailgun.region' => 'us',
|
|
'mailgun.from' => 'Budget Planner <budget@example.com>',
|
|
'security.2fa_required' => false,
|
|
'security.2fa_method' => 'totp',
|
|
];
|
|
|
|
public function __construct(private PDO $pdo)
|
|
{
|
|
$this->seed();
|
|
}
|
|
|
|
public function get(string $key, mixed $fallback = null): mixed
|
|
{
|
|
$statement = $this->pdo->prepare('SELECT setting_value FROM settings WHERE setting_key = :key');
|
|
$statement->execute(['key' => $key]);
|
|
$value = $statement->fetchColumn();
|
|
if ($value === false) {
|
|
return self::DEFAULTS[$key] ?? $fallback;
|
|
}
|
|
$decoded = json_decode((string) $value, true);
|
|
return json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
|
|
}
|
|
|
|
public function has(string $key): bool
|
|
{
|
|
$statement = $this->pdo->prepare('SELECT COUNT(*) FROM settings WHERE setting_key = :key');
|
|
$statement->execute(['key' => $key]);
|
|
return (int) $statement->fetchColumn() > 0;
|
|
}
|
|
|
|
public function set(string $key, mixed $value): void
|
|
{
|
|
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
if ($encoded === false) {
|
|
throw new \InvalidArgumentException('Setting value cannot be encoded.');
|
|
}
|
|
$sql = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql'
|
|
? 'INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value)
|
|
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = CURRENT_TIMESTAMP'
|
|
: 'INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value)
|
|
ON CONFLICT(setting_key) DO UPDATE SET setting_value = excluded.setting_value, updated_at = CURRENT_TIMESTAMP';
|
|
$statement = $this->pdo->prepare($sql);
|
|
$statement->execute(['key' => $key, 'value' => $encoded]);
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
public function all(): array
|
|
{
|
|
$settings = self::DEFAULTS;
|
|
foreach ($this->pdo->query('SELECT setting_key, setting_value FROM settings')->fetchAll() as $row) {
|
|
$decoded = json_decode((string) $row['setting_value'], true);
|
|
$settings[$row['setting_key']] = json_last_error() === JSON_ERROR_NONE ? $decoded : $row['setting_value'];
|
|
}
|
|
return $settings;
|
|
}
|
|
|
|
public function module(string $name): bool
|
|
{
|
|
return (bool) $this->get('modules.' . $name, true);
|
|
}
|
|
|
|
private function seed(): void
|
|
{
|
|
$count = (int) $this->pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn();
|
|
if ($count > 0) {
|
|
return;
|
|
}
|
|
foreach (self::DEFAULTS as $key => $value) {
|
|
$this->set($key, $value);
|
|
}
|
|
}
|
|
}
|