$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 = [], 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. Let’s 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,
]);
}