Files
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

646 lines
29 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use Budget\ExportService;
use Budget\Mailgun;
use Budget\ReminderService;
use Budget\Support;
use Budget\Totp;
require __DIR__ . '/../app/bootstrap.php';
if (!class_exists('Support', false)) {
class_alias(Support::class, 'Support');
}
function h(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function url(string $route, array $parameters = []): string
{
return '?' . http_build_query(['route' => $route] + $parameters);
}
function csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="' . h(Support::csrf()) . '">';
}
function icon(string $name, string $class = ''): string
{
$paths = [
'home' => '<path d="M3 10.8 12 3l9 7.8"/><path d="M5.5 9.5V21h13V9.5M9 21v-7h6v7"/>',
'expense' => '<path d="M4 7h16M4 12h16M4 17h10"/><path d="m17 15 3 3-3 3"/>',
'calendar' => '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/>',
'chart' => '<path d="M4 19V9M10 19V5M16 19v-7M22 19H2"/>',
'vault' => '<rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="12" cy="12" r="3"/><path d="M12 9V7M12 17v-2M9 12H7M17 12h-2"/>',
'settings' => '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z"/>',
'data' => '<path d="M8 7h11l-3-3M16 17H5l3 3"/><path d="m19 7-3 3M5 17l3-3"/>',
'plus' => '<path d="M12 5v14M5 12h14"/>',
'wallet' => '<path d="M4 6.5h14a2 2 0 0 1 2 2V19H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12"/><path d="M20 11h-5a2 2 0 0 0 0 4h5"/>',
'bell' => '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>',
'edit' => '<path d="m4 20 4.5-1 10-10a2.1 2.1 0 0 0-3-3l-10 10L4 20Z"/><path d="m14 7 3 3"/>',
'trash' => '<path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"/>',
'search' => '<circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/>',
'menu' => '<path d="M4 7h16M4 12h16M4 17h16"/>',
'close' => '<path d="m6 6 12 12M18 6 6 18"/>',
'sun' => '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
'logout' => '<path d="M10 5H5v14h5M14 8l4 4-4 4M8 12h10"/>',
'clock' => '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
];
$body = $paths[$name] ?? $paths['home'];
return '<svg class="icon ' . h($class) . '" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' . $body . '</svg>';
}
function redirect_to(string $route, array $parameters = []): never
{
header('Location: ' . url($route, $parameters));
exit;
}
function render_view(string $view, array $data = [], string|bool $layout = false): never
{
global $settings, $auth;
extract($data, EXTR_SKIP);
$flashes = Support::pullFlash();
ob_start();
require APP_ROOT . '/views/' . $view . '.php';
$content = (string) ob_get_clean();
$layoutView = $layout === true ? 'auth-layout' : (is_string($layout) && $layout !== '' ? $layout : 'layout');
require APP_ROOT . '/views/' . $layoutView . '.php';
exit;
}
function onboarding_is_complete(): bool
{
global $settings, $onboarding;
if (!$settings->has('onboarding.completed')) {
$settings->set('onboarding.completed', $onboarding->hasFinancialData());
}
return (bool) $settings->get('onboarding.completed', false);
}
$route = (string) ($_GET['route'] ?? ($auth->check() ? 'dashboard' : 'login'));
$month = Support::month((string) ($_GET['month'] ?? $_POST['month'] ?? date('Y-m')));
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = (string) ($_POST['action'] ?? '');
try {
Support::verifyCsrf($_POST['csrf_token'] ?? null);
if ($action === 'setup') {
if (!$auth->needsSetup()) {
throw new RuntimeException('Setup has already been completed.');
}
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
$password = (string) ($_POST['password'] ?? '');
if (!$email) {
throw new RuntimeException('Enter a valid administrator email.');
}
if (strlen($password) < 10 || $password !== (string) ($_POST['password_confirmation'] ?? '')) {
throw new RuntimeException('Use at least 10 characters and make sure both passwords match.');
}
$auth->setup((string) $email, $password, false);
Support::flash('success', 'Account created. Lets build your starting plan.');
redirect_to('welcome');
}
if ($action === 'login') {
$result = $auth->attempt((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
if ($result === 'ok') {
redirect_to('dashboard');
}
if ($result === '2fa') {
redirect_to('two-factor');
}
throw new RuntimeException(
$result === 'unconfigured_2fa'
? 'Two-factor authentication is required but not configured for this account.'
: 'Email or password was not recognized.'
);
}
if ($action === 'verify_2fa') {
if (!$auth->verifyTwoFactor((string) ($_POST['code'] ?? ''))) {
throw new RuntimeException('That authentication code is invalid or expired.');
}
redirect_to('dashboard');
}
if (!$auth->check()) {
redirect_to('login');
}
if ($action === 'logout') {
$auth->logout();
header('Location: ' . url('login'));
exit;
}
if ($action === 'onboarding.complete') {
$billCount = max(0, min(50, (int) ($_POST['bill_count'] ?? 0)));
$bills = isset($_POST['bills']) && is_array($_POST['bills'])
? array_values($_POST['bills'])
: [];
if (count($bills) !== $billCount) {
throw new RuntimeException('The bill list changed before it could be saved. Please generate the fields again.');
}
$availableExtraCategories = ['TV', 'Cellular', 'Internet', 'Disney+', 'Netflix', 'Luxury', 'Entertainment', 'Travel'];
$requestedExtraCategories = isset($_POST['extra_categories']) && is_array($_POST['extra_categories'])
? array_values(array_filter($_POST['extra_categories'], 'is_string'))
: [];
$extraCategories = array_values(array_intersect($availableExtraCategories, $requestedExtraCategories));
$result = $onboarding->complete(
date('Y-m'),
(float) ($_POST['current_cash'] ?? 0),
$bills,
$extraCategories,
isset($_POST['enable_dead_account']) ? (float) ($_POST['dead_amount'] ?? 0) : 0
);
Support::audit($pdo, $auth->id(), 'onboarding.completed', 'monthly_plan', null, $result);
Support::flash(
'success',
sprintf(
'Starting plan created with %d bill%s and %d categor%s.',
$result['bills'],
$result['bills'] === 1 ? '' : 's',
$result['categories'],
$result['categories'] === 1 ? 'y' : 'ies'
)
);
redirect_to('dashboard', ['month' => date('Y-m')]);
}
if (!onboarding_is_complete()) {
redirect_to('welcome');
}
if ($action === 'plan.save') {
$budget->savePlan(
$month,
(float) ($_POST['starting_income'] ?? 0),
(float) ($_POST['rollover_in'] ?? 0),
isset($_POST['rolling_enabled']),
(string) ($_POST['notes'] ?? '')
);
Support::audit($pdo, $auth->id(), 'plan.updated', 'monthly_plan', null, ['month' => $month]);
Support::flash('success', 'Monthly starting point updated.');
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'plan.refresh_rollover') {
$rollover = $budget->refreshRollover($month);
Support::flash('success', 'Rollover refreshed to ' . Support::money($rollover, (string) $settings->get('app.currency_symbol', '$')) . '.');
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'income.add') {
$id = $budget->addIncome(
$month,
(string) ($_POST['label'] ?? ''),
(float) ($_POST['amount'] ?? 0),
(string) ($_POST['received_on'] ?? date('Y-m-d')),
(string) ($_POST['notes'] ?? '')
);
Support::audit($pdo, $auth->id(), 'income.created', 'income', $id, ['month' => $month]);
Support::flash('success', 'Income added and automatic withholding applied.');
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'income.update') {
$budget->updateIncome(
(int) ($_POST['id'] ?? 0),
$month,
(string) ($_POST['label'] ?? ''),
(float) ($_POST['amount'] ?? 0),
(string) ($_POST['received_on'] ?? date('Y-m-d')),
(string) ($_POST['notes'] ?? '')
);
Support::audit($pdo, $auth->id(), 'income.updated', 'income', (int) ($_POST['id'] ?? 0), ['month' => $month]);
Support::flash('success', 'Income entry updated.');
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'income.delete') {
$budget->deleteIncome((int) ($_POST['id'] ?? 0));
Support::audit($pdo, $auth->id(), 'income.deleted', 'income', (int) ($_POST['id'] ?? 0));
Support::flash('success', 'Income entry removed.');
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'category.add') {
$id = $budget->addCategory(
(string) ($_POST['name'] ?? ''),
(string) ($_POST['color'] ?? '#7c5cff'),
(float) ($_POST['monthly_limit'] ?? 0)
);
Support::audit($pdo, $auth->id(), 'category.created', 'category', $id);
Support::flash('success', 'Category created.');
$returnRoute = (string) ($_POST['return_route'] ?? 'dashboard');
redirect_to(in_array($returnRoute, ['dashboard', 'settings'], true) ? $returnRoute : 'dashboard', $returnRoute === 'dashboard' ? ['month' => $month] : []);
}
if ($action === 'category.update') {
$budget->updateCategory(
(int) ($_POST['id'] ?? 0),
(string) ($_POST['name'] ?? ''),
(string) ($_POST['color'] ?? '#7c5cff'),
(float) ($_POST['monthly_limit'] ?? 0)
);
Support::audit($pdo, $auth->id(), 'category.updated', 'category', (int) ($_POST['id'] ?? 0));
Support::flash('success', 'Category updated.');
redirect_to('settings');
}
if ($action === 'category.archive') {
$budget->archiveCategory((int) ($_POST['id'] ?? 0));
Support::audit($pdo, $auth->id(), 'category.archived', 'category', (int) ($_POST['id'] ?? 0));
Support::flash('success', 'Category archived. Historical records were preserved.');
redirect_to('settings');
}
if ($action === 'item.add') {
$id = $budget->addBudgetItem(
$month,
!empty($_POST['category_id']) ? (int) $_POST['category_id'] : null,
(string) ($_POST['name'] ?? ''),
(float) ($_POST['planned_amount'] ?? 0),
!empty($_POST['due_date']) ? (string) $_POST['due_date'] : null,
(string) ($_POST['recurrence'] ?? 'none'),
(string) ($_POST['notes'] ?? '')
);
Support::audit($pdo, $auth->id(), 'budget_item.created', 'budget_item', $id, ['month' => $month]);
Support::flash('success', 'Budget item added.');
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'item.delete') {
$budget->deleteBudgetItem((int) ($_POST['id'] ?? 0));
Support::audit($pdo, $auth->id(), 'budget_item.deleted', 'budget_item', (int) ($_POST['id'] ?? 0));
Support::flash('success', 'Budget item removed. Existing expenses were preserved.');
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'item.update') {
$budget->updateBudgetItem(
(int) ($_POST['id'] ?? 0),
$month,
!empty($_POST['category_id']) ? (int) $_POST['category_id'] : null,
(string) ($_POST['name'] ?? ''),
(float) ($_POST['planned_amount'] ?? 0),
!empty($_POST['due_date']) ? (string) $_POST['due_date'] : null,
(string) ($_POST['recurrence'] ?? 'none'),
(string) ($_POST['notes'] ?? '')
);
Support::audit($pdo, $auth->id(), 'budget_item.updated', 'budget_item', (int) ($_POST['id'] ?? 0), ['month' => $month]);
Support::flash('success', 'Budget item updated.');
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'transaction.add') {
$id = $budget->addTransaction(
$month,
!empty($_POST['budget_item_id']) ? (int) $_POST['budget_item_id'] : null,
!empty($_POST['category_id']) ? (int) $_POST['category_id'] : null,
(string) ($_POST['merchant'] ?? ''),
(float) ($_POST['amount'] ?? 0),
(string) ($_POST['transacted_on'] ?? date('Y-m-d')),
(string) ($_POST['notes'] ?? '')
);
Support::audit($pdo, $auth->id(), 'transaction.created', 'transaction', $id, ['month' => $month]);
Support::flash('success', 'Expense recorded and budget progress updated.');
redirect_to('transactions', ['month' => $month]);
}
if ($action === 'transaction.delete') {
$budget->deleteTransaction((int) ($_POST['id'] ?? 0));
Support::audit($pdo, $auth->id(), 'transaction.deleted', 'transaction', (int) ($_POST['id'] ?? 0));
Support::flash('success', 'Expense and its automatic dormant transfer were removed.');
redirect_to('transactions', ['month' => $month]);
}
if ($action === 'transaction.update') {
$budget->updateTransaction(
(int) ($_POST['id'] ?? 0),
$month,
!empty($_POST['budget_item_id']) ? (int) $_POST['budget_item_id'] : null,
!empty($_POST['category_id']) ? (int) $_POST['category_id'] : null,
(string) ($_POST['merchant'] ?? ''),
(float) ($_POST['amount'] ?? 0),
(string) ($_POST['transacted_on'] ?? date('Y-m-d')),
(string) ($_POST['notes'] ?? '')
);
Support::audit($pdo, $auth->id(), 'transaction.updated', 'transaction', (int) ($_POST['id'] ?? 0), ['month' => $month]);
Support::flash('success', 'Expense updated and budget progress recalculated.');
redirect_to('transactions', ['month' => $month]);
}
if ($action === 'dead.move') {
$id = $budget->moveDeadAccount(
$month,
(string) ($_POST['direction'] ?? 'deposit'),
(float) ($_POST['amount'] ?? 0),
(string) ($_POST['notes'] ?? ''),
(string) ($_POST['destination_type'] ?? 'income'),
!empty($_POST['destination_id']) ? (int) $_POST['destination_id'] : null
);
Support::audit($pdo, $auth->id(), 'dead_account.moved', 'dead_entry', $id, ['month' => $month]);
Support::flash('success', 'Dormant account transfer recorded.');
redirect_to('dead-account', ['month' => $month]);
}
if ($action === 'event.add') {
$id = $budget->addCalendarEvent(
$month,
(string) ($_POST['title'] ?? ''),
(string) ($_POST['event_date'] ?? date('Y-m-d')),
(string) ($_POST['event_type'] ?? 'note'),
(string) ($_POST['notes'] ?? '')
);
Support::audit($pdo, $auth->id(), 'calendar_event.created', 'calendar_event', $id);
Support::flash('success', 'Calendar event added.');
redirect_to('calendar', ['month' => $month]);
}
if ($action === 'event.delete') {
$budget->deleteCalendarEvent((int) ($_POST['id'] ?? 0));
Support::audit($pdo, $auth->id(), 'calendar_event.deleted', 'calendar_event', (int) ($_POST['id'] ?? 0));
Support::flash('success', 'Calendar event removed.');
redirect_to('calendar', ['month' => $month]);
}
if ($action === 'settings.save') {
$postSetting = static function (string $key, mixed $default = ''): mixed {
if (array_key_exists($key, $_POST)) {
return $_POST[$key];
}
return $_POST[str_replace('.', '_', $key)] ?? $default;
};
$stringSettings = [
'app.name', 'app.currency_symbol', 'app.timezone', 'theme.default',
'dead.income_mode', 'dead.transaction_mode', 'alerts.email',
'mailgun.domain', 'mailgun.region', 'mailgun.from',
];
foreach ($stringSettings as $key) {
$settings->set($key, trim((string) $postSetting($key)));
}
foreach (['dead.income_value', 'dead.transaction_value'] as $key) {
$settings->set($key, max(0, (float) $postSetting($key, 0)));
}
foreach (['income', 'bills', 'calendar', 'dead_account', 'comparisons', 'alerts', 'import_export'] as $module) {
$settings->set('modules.' . $module, isset($_POST['modules_' . $module]));
}
$settings->set('dead.hide_balance', isset($_POST['dead_hide_balance']));
$windows = array_values(array_unique(array_filter(array_map(
static fn (string $value): int => max(0, (int) trim($value)),
explode(',', (string) $postSetting('alerts.windows', '7,3,1'))
), static fn (int $value): bool => $value <= 365)));
$times = array_values(array_filter(array_map('trim', explode(',', (string) $postSetting('alerts.times', '09:00'))), static fn (string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)));
$settings->set('alerts.windows', $windows ?: [7, 3, 1]);
$settings->set('alerts.times', $times ?: ['09:00']);
if (trim((string) $postSetting('mailgun.api_key')) !== '') {
$settings->set('mailgun.api_key', trim((string) $postSetting('mailgun.api_key')));
} elseif (isset($_POST['clear_mailgun_key'])) {
$settings->set('mailgun.api_key', '');
}
$requireTwoFactor = isset($_POST['security_2fa_required']);
$twoFactorMethod = in_array((string) ($_POST['security_2fa_method'] ?? 'totp'), ['totp', 'email'], true)
? (string) $_POST['security_2fa_method']
: 'totp';
$user = $auth->user();
if ($requireTwoFactor && $twoFactorMethod === 'email' && !(new Mailgun($settings))->configured()) {
throw new RuntimeException('Complete the Mailgun fields before requiring email two-factor authentication.');
}
if ($requireTwoFactor && $twoFactorMethod === 'totp' && empty($user['totp_secret'])) {
$secret = Totp::generateSecret();
$statement = $pdo->prepare('UPDATE users SET totp_secret = :secret, updated_at = CURRENT_TIMESTAMP WHERE id = :id');
$statement->execute(['secret' => $secret, 'id' => $auth->id()]);
$_SESSION['new_totp_secret'] = $secret;
}
$settings->set('security.2fa_method', $twoFactorMethod);
$settings->set('security.2fa_required', $requireTwoFactor);
Support::audit($pdo, $auth->id(), 'settings.updated');
Support::flash('success', 'Planner configuration saved.');
redirect_to('settings');
}
if ($action === '2fa.regenerate') {
$secret = Totp::generateSecret();
$statement = $pdo->prepare('UPDATE users SET totp_secret = :secret, updated_at = CURRENT_TIMESTAMP WHERE id = :id');
$statement->execute(['secret' => $secret, 'id' => $auth->id()]);
$_SESSION['new_totp_secret'] = $secret;
Support::audit($pdo, $auth->id(), '2fa.regenerated');
Support::flash('success', 'A new two-factor secret was generated.');
redirect_to('settings');
}
if ($action === 'alerts.run') {
$runner = new ReminderService($pdo, $settings, new Mailgun($settings));
$result = $runner->run(true);
$message = sprintf('%d reminder(s) sent; %d skipped.', $result['sent'], $result['skipped']);
if ($result['errors'] !== []) {
$message .= ' ' . implode(' ', $result['errors']);
Support::flash('error', $message);
} else {
Support::flash('success', $message);
}
redirect_to('settings');
}
if ($action === 'data.import') {
if (!isset($_FILES['planner_file']) || !is_uploaded_file($_FILES['planner_file']['tmp_name'])) {
throw new RuntimeException('Choose a planner JSON package to import.');
}
$json = file_get_contents($_FILES['planner_file']['tmp_name']);
if ($json === false) {
throw new RuntimeException('The uploaded package could not be read.');
}
$exporter = new ExportService($pdo, $settings, $budget);
$result = $exporter->importPackage($json, isset($_POST['replace_existing']));
Support::audit($pdo, $auth->id(), 'data.imported', null, null, $result);
Support::flash('success', 'Planner package imported successfully.');
redirect_to('data');
}
if ($action === 'database.migrate') {
$config = [
'host' => trim((string) ($_POST['host'] ?? '127.0.0.1')),
'port' => (int) ($_POST['port'] ?? 3306),
'database' => trim((string) ($_POST['database'] ?? '')),
'username' => trim((string) ($_POST['username'] ?? '')),
'password' => (string) ($_POST['password'] ?? ''),
'charset' => 'utf8mb4',
];
if ($config['database'] === '' || $config['username'] === '') {
throw new RuntimeException('MySQL database name and username are required.');
}
$count = $database->migrateSqliteToMysql($config, APP_ROOT);
Support::flash('success', $count . ' records copied. MySQL mode is active on the next request.');
redirect_to('data');
}
throw new RuntimeException('Unknown action.');
} catch (Throwable $exception) {
Support::flash('error', $exception->getMessage());
$fallbackRoute = in_array($route, ['login', 'setup', 'two-factor', 'welcome'], true) ? $route : ($route ?: 'dashboard');
if ($auth->check() && !onboarding_is_complete()) {
$fallbackRoute = 'welcome';
}
redirect_to($fallbackRoute, $auth->check() && $month ? ['month' => $month] : []);
}
}
if ($route === 'logout') {
redirect_to('login');
}
if ($auth->needsSetup()) {
render_view('setup', ['title' => 'Create your planner'], true);
}
if (!$auth->check()) {
if ($route === 'two-factor' && isset($_SESSION['pending_2fa_user'])) {
render_view('two-factor', [
'title' => 'Two-factor verification',
'twoFactorMethod' => (string) ($_SESSION['pending_2fa_method'] ?? 'totp'),
], true);
}
render_view('login', ['title' => 'Administrator sign in'], true);
}
$onboardingComplete = onboarding_is_complete();
if (!$onboardingComplete && $route !== 'welcome') {
redirect_to('welcome');
}
if ($onboardingComplete && $route === 'welcome') {
redirect_to('dashboard', ['month' => date('Y-m')]);
}
Support::trackVisit($pdo, $route);
if ($route === 'welcome') {
render_view('onboarding', [
'title' => 'Build your starting plan',
'route' => 'welcome',
'month' => date('Y-m'),
'today' => date('Y-m-d'),
'symbol' => (string) $settings->get('app.currency_symbol', '$'),
'user' => $auth->user(),
], 'onboarding-layout');
}
if (in_array($route, ['export-json', 'export-html', 'export-pdf'], true)) {
$exporter = new ExportService($pdo, $settings, $budget);
if ($route === 'export-json') {
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="neon-ledger-' . date('Ymd-His') . '.json"');
echo $exporter->package();
exit;
}
if ($route === 'export-html') {
header('Content-Type: text/html; charset=utf-8');
header('Content-Disposition: attachment; filename="budget-' . $month . '.html"');
echo $exporter->html($month);
exit;
}
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="budget-' . $month . '.pdf"');
echo $exporter->pdf($month);
exit;
}
$plan = $budget->ensureMonth($month);
$summary = $budget->summary($month);
$symbol = (string) $settings->get('app.currency_symbol', '$');
switch ($route) {
case 'transactions':
render_view('transactions', [
'title' => 'Expenses',
'route' => $route,
'month' => $month,
'summary' => $summary,
'items' => $budget->budgetItems($month),
'categories' => $budget->categories(),
'transactions' => $budget->transactions($month),
'symbol' => $symbol,
]);
case 'calendar':
render_view('calendar', [
'title' => 'Bill calendar',
'route' => $route,
'month' => $month,
'entries' => $budget->calendarEntries($month),
'symbol' => $symbol,
]);
case 'comparisons':
$year = (int) substr($month, 0, 4);
render_view('comparisons', [
'title' => 'Comparisons',
'route' => $route,
'month' => $month,
'comparison' => $budget->comparison($month),
'yearly' => $budget->yearlySummary($year),
'categoryStats' => $budget->categoryStats($month),
'symbol' => $symbol,
]);
case 'dead-account':
render_view('dead-account', [
'title' => 'Dormant account',
'route' => $route,
'month' => $month,
'balance' => $budget->deadBalance(),
'entries' => $budget->deadEntries(null, 150),
'items' => $budget->budgetItems($month),
'hideBalance' => (bool) $settings->get('dead.hide_balance', true),
'symbol' => $symbol,
]);
case 'settings':
$user = $auth->user();
$newSecret = $_SESSION['new_totp_secret'] ?? null;
unset($_SESSION['new_totp_secret']);
render_view('settings', [
'title' => 'Settings',
'route' => $route,
'month' => $month,
'allSettings' => $settings->all(),
'user' => $user,
'newSecret' => $newSecret,
'totpUri' => $newSecret ? Totp::uri($newSecret, (string) $user['email'], (string) $settings->get('app.name', 'Neon Ledger')) : null,
'mailgunConfigured' => (new Mailgun($settings))->configured(),
'categories' => $budget->categories(),
]);
case 'data':
render_view('data', [
'title' => 'Import & export',
'route' => $route,
'month' => $month,
'databaseDriver' => $database->driver(),
'databaseConfig' => $database->config(),
]);
case 'dashboard':
default:
render_view('dashboard', [
'title' => 'Command center',
'route' => 'dashboard',
'month' => $month,
'plan' => $plan,
'summary' => $summary,
'incomes' => $budget->incomes($month),
'items' => $budget->budgetItems($month),
'categories' => $budget->categories(),
'categoryStats' => $budget->categoryStats($month),
'comparison' => $budget->comparison($month, 6),
'previousSummary' => $budget->summary(date('Y-m', strtotime($month . '-01 -1 month')), false),
'upcomingBills' => $budget->upcomingBills($month),
'recentActivity' => $budget->recentActivity($month),
'visitorStats' => $budget->visitorStats(),
'deadBalance' => $budget->deadBalance(),
'symbol' => $symbol,
]);
}