Files
budget/app/Settings.php
T
Ty Clifford 6686388834 - Implemented the complete first-run experience:
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.
2026-06-15 14:51:44 -04:00

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