$route] + $parameters); } function csrf_field(): string { return ''; } function icon(string $name, string $class = ''): string { $paths = [ 'home' => '', 'expense' => '', 'calendar' => '', 'chart' => '', 'vault' => '', 'settings' => '', 'data' => '', 'plus' => '', 'wallet' => '', 'bell' => '', 'edit' => '', 'trash' => '', 'search' => '', 'menu' => '', 'close' => '', 'sun' => '', 'logout' => '', 'clock' => '', ]; $body = $paths[$name] ?? $paths['home']; return ''; } function redirect_to(string $route, array $parameters = []): never { header('Location: ' . url($route, $parameters)); exit; } function render_view(string $view, array $data = [], bool $authLayout = 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(); require APP_ROOT . '/views/' . ($authLayout ? 'auth-layout.php' : 'layout.php'); exit; } $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.'); } $secret = $auth->setup((string) $email, $password, isset($_POST['enable_2fa'])); if ($secret) { $_SESSION['new_totp_secret'] = $secret; } Support::flash('success', 'Administrator account created. Your planner is ready.'); redirect_to($secret ? 'settings' : 'dashboard'); } 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 === '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']); $user = $auth->user(); if ($requireTwoFactor && 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_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'], true) ? $route : ($route ?: 'dashboard'); 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'], true); } render_view('login', ['title' => 'Administrator sign in'], true); } Support::trackVisit($pdo, $route); 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, ]); }