From 66863888348a65cd902548edf198bba33c3e1cbc Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Mon, 15 Jun 2026 14:51:44 -0400 Subject: [PATCH] - 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. --- app/Auth.php | 77 +++++++- app/Mailgun.php | 2 +- app/OnboardingService.php | 355 ++++++++++++++++++++++++++++++++++++ app/Settings.php | 8 + app/bootstrap.php | 2 + public/assets/app.css | 126 ++++++++++++- public/assets/app.js | 172 +++++++++++++++++ public/index.php | 99 ++++++++-- scripts/onboarding-test.php | 68 +++++++ views/auth-layout.php | 12 +- views/onboarding-layout.php | 49 +++++ views/onboarding.php | 184 +++++++++++++++++++ views/settings.php | 20 +- views/setup.php | 13 +- views/two-factor.php | 4 +- 15 files changed, 1159 insertions(+), 32 deletions(-) create mode 100644 app/OnboardingService.php create mode 100644 scripts/onboarding-test.php create mode 100644 views/onboarding-layout.php create mode 100644 views/onboarding.php diff --git a/app/Auth.php b/app/Auth.php index 8fee7df..aa41690 100644 --- a/app/Auth.php +++ b/app/Auth.php @@ -31,9 +31,11 @@ final class Auth 'password_hash' => password_hash($password, PASSWORD_DEFAULT), 'secret' => $secret, ]); + $userId = (int) $this->pdo->lastInsertId(); $this->settings->set('alerts.email', strtolower(trim($email))); $this->settings->set('security.2fa_required', $enableTwoFactor); - $_SESSION['user_id'] = (int) $this->pdo->lastInsertId(); + $this->settings->set('onboarding.completed', false); + $_SESSION['user_id'] = $userId; session_regenerate_id(true); return $secret; } @@ -48,10 +50,16 @@ final class Auth } if ((bool) $this->settings->get('security.2fa_required', false)) { - if (empty($user['totp_secret'])) { - return 'unconfigured_2fa'; + $method = (string) $this->settings->get('security.2fa_method', 'totp'); + if ($method === 'email') { + $this->startEmailChallenge($user); + } else { + if (empty($user['totp_secret'])) { + return 'unconfigured_2fa'; + } + $_SESSION['pending_2fa_user'] = (int) $user['id']; + $_SESSION['pending_2fa_method'] = 'totp'; } - $_SESSION['pending_2fa_user'] = (int) $user['id']; return '2fa'; } @@ -65,13 +73,16 @@ final class Auth if ($userId === 0) { return false; } + if (($_SESSION['pending_2fa_method'] ?? 'totp') === 'email') { + return $this->verifyEmailChallenge($userId, $code); + } $statement = $this->pdo->prepare('SELECT totp_secret FROM users WHERE id = :id'); $statement->execute(['id' => $userId]); $secret = (string) $statement->fetchColumn(); if ($secret === '' || !Totp::verify($secret, $code)) { return false; } - unset($_SESSION['pending_2fa_user']); + unset($_SESSION['pending_2fa_user'], $_SESSION['pending_2fa_method']); $this->completeLogin($userId); return true; } @@ -111,4 +122,60 @@ final class Auth $_SESSION['user_id'] = $userId; session_regenerate_id(true); } + + private function startEmailChallenge(array $user): void + { + $mailgun = new Mailgun($this->settings); + if (!$mailgun->configured()) { + throw new \RuntimeException('Email two-factor authentication requires complete Mailgun settings.'); + } + $code = (string) random_int(100000, 999999); + $appName = (string) $this->settings->get('app.name', 'Neon Ledger'); + $mailgun->send( + (string) $user['email'], + $appName . ' sign-in code', + '
' + . '

Your sign-in code

' + . '

Enter this code to finish signing in. It expires in 10 minutes.

' + . '

' . $code . '

' + . '

If you did not try to sign in, you can ignore this email.

' + . '
' + ); + $_SESSION['pending_2fa_user'] = (int) $user['id']; + $_SESSION['pending_2fa_method'] = 'email'; + $_SESSION['pending_2fa_code'] = password_hash($code, PASSWORD_DEFAULT); + $_SESSION['pending_2fa_expires'] = time() + 600; + $_SESSION['pending_2fa_attempts'] = 0; + } + + private function verifyEmailChallenge(int $userId, string $code): bool + { + $expires = (int) ($_SESSION['pending_2fa_expires'] ?? 0); + $attempts = (int) ($_SESSION['pending_2fa_attempts'] ?? 0); + $hash = (string) ($_SESSION['pending_2fa_code'] ?? ''); + if ($expires < time() || $attempts >= 5 || $hash === '') { + $this->clearPendingChallenge(); + return false; + } + + $_SESSION['pending_2fa_attempts'] = $attempts + 1; + if (!preg_match('/^\d{6}$/', $code) || !password_verify($code, $hash)) { + return false; + } + + $this->clearPendingChallenge(); + $this->completeLogin($userId); + return true; + } + + private function clearPendingChallenge(): void + { + unset( + $_SESSION['pending_2fa_user'], + $_SESSION['pending_2fa_method'], + $_SESSION['pending_2fa_code'], + $_SESSION['pending_2fa_expires'], + $_SESSION['pending_2fa_attempts'] + ); + } } diff --git a/app/Mailgun.php b/app/Mailgun.php index 8f370a2..b77d32a 100644 --- a/app/Mailgun.php +++ b/app/Mailgun.php @@ -52,7 +52,7 @@ final class Mailgun preg_match('/\s(\d{3})\s/', $statusLine, $matches); $status = (int) ($matches[1] ?? 500); if ($response === false || $status >= 400) { - throw new RuntimeException('Mailgun rejected the reminder: ' . ($response ?: $statusLine)); + throw new RuntimeException('Mailgun rejected the message: ' . ($response ?: $statusLine)); } return ['status' => $status, 'body' => $response]; } diff --git a/app/OnboardingService.php b/app/OnboardingService.php new file mode 100644 index 0000000..8e20552 --- /dev/null +++ b/app/OnboardingService.php @@ -0,0 +1,355 @@ + '#49a8ff', + 'Housing' => '#8b5cf6', + 'Communications' => '#ff5b9d', + 'Subscriptions' => '#a98bff', + 'Insurance' => '#ffad5c', + 'Transportation' => '#ff6473', + 'Debt' => '#ff6473', + 'Food' => '#49e8a4', + 'Health' => '#ff5b9d', + 'Childcare' => '#ffad5c', + 'Education' => '#49a8ff', + 'TV' => '#ff5b9d', + 'Cellular' => '#49a8ff', + 'Internet' => '#49e8a4', + 'Disney+' => '#8b5cf6', + 'Netflix' => '#ff6473', + 'Luxury' => '#ffad5c', + ]; + + public function __construct(private PDO $pdo, private Settings $settings) + { + } + + public function hasFinancialData(): bool + { + $tables = [ + 'monthly_plans', + 'income_entries', + 'categories', + 'budget_items', + 'transactions', + 'dead_account_entries', + ]; + foreach ($tables as $table) { + if ((int) $this->pdo->query('SELECT COUNT(*) FROM ' . $table)->fetchColumn() > 0) { + return true; + } + } + return false; + } + + /** + * @param list> $bills + * @param list $extraCategories + * @return array{bills:int, paid:int, categories:int, reserved:float} + */ + public function complete( + string $month, + float $cash, + array $bills, + array $extraCategories, + float $deadAmount + ): array { + if ((bool) $this->settings->get('onboarding.completed', false)) { + throw new RuntimeException('Onboarding has already been completed.'); + } + + $month = Support::month($month); + $cash = round($cash, 2); + $deadAmount = round($deadAmount, 2); + if ($cash < 0) { + throw new RuntimeException('Current cash cannot be negative.'); + } + if ($deadAmount < 0) { + throw new RuntimeException('Dormant reserve cannot be negative.'); + } + if (count($bills) > 50) { + throw new RuntimeException('Onboarding supports up to 50 bills at a time.'); + } + + $normalizedBills = []; + $paidTotal = 0.0; + $deadPullTotal = 0.0; + foreach ($bills as $index => $bill) { + if (!is_array($bill)) { + throw new RuntimeException('Bill ' . ($index + 1) . ' is not valid.'); + } + $name = trim((string) ($bill['name'] ?? '')); + $amount = round((float) ($bill['amount'] ?? 0), 2); + $dueDate = trim((string) ($bill['due_date'] ?? '')); + $recurrence = (string) ($bill['recurrence'] ?? 'monthly'); + $paidNow = !empty($bill['paid_now']); + $category = trim((string) ($bill['category'] ?? '')); + + if ($name === '') { + throw new RuntimeException('Bill ' . ($index + 1) . ' needs a name.'); + } + if ($amount < 0) { + throw new RuntimeException($name . ' cannot have a negative target.'); + } + if ($paidNow && $amount <= 0) { + throw new RuntimeException($name . ' needs an amount before it can be marked paid.'); + } + if ($dueDate !== '' && !$this->dateIsInMonth($dueDate, $month)) { + throw new RuntimeException($name . ' needs a due date within ' . date('F Y', strtotime($month . '-01')) . '.'); + } + if (!in_array($recurrence, ['none', 'monthly', 'yearly'], true)) { + $recurrence = 'monthly'; + } + if ($category === '' || $category === '__bill__') { + $category = $name; + } + if ($category === '__custom__') { + $category = trim((string) ($bill['custom_category'] ?? '')); + } + if ($category === '') { + throw new RuntimeException($name . ' needs a category.'); + } + + $deadPull = $paidNow ? $this->automaticTransactionPull($amount) : 0.0; + if ($paidNow) { + $paidTotal += $amount; + $deadPullTotal += $deadPull; + } + $normalizedBills[] = [ + 'name' => $this->truncate($name, 255), + 'amount' => $amount, + 'due_date' => $dueDate ?: null, + 'recurrence' => $recurrence, + 'paid_now' => $paidNow, + 'category' => $this->truncate($category, 191), + 'dead_pull' => $deadPull, + ]; + } + + $availableAfterPayments = $cash - $paidTotal - $deadPullTotal; + if ($availableAfterPayments < -0.0001) { + throw new RuntimeException('Bills marked paid exceed the cash available right now.'); + } + if ($deadAmount > $availableAfterPayments + 0.0001) { + throw new RuntimeException('The dormant reserve is larger than the cash left after onboarding payments.'); + } + + $extraCategories = array_values(array_unique(array_filter(array_map( + static fn (mixed $name): string => trim((string) $name), + $extraCategories + )))); + + $createdCategories = []; + $paidCount = 0; + $this->pdo->beginTransaction(); + try { + $planId = $this->upsertPlan($month, $cash); + + foreach ($extraCategories as $categoryName) { + $categoryName = $this->truncate($categoryName, 191); + $categoryId = $this->findOrCreateCategory($categoryName); + $createdCategories[$categoryId] = true; + } + + foreach ($normalizedBills as $bill) { + $categoryId = $this->findOrCreateCategory((string) $bill['category']); + $createdCategories[$categoryId] = true; + $seriesKey = $bill['recurrence'] === 'none' ? null : bin2hex(random_bytes(12)); + $statement = $this->pdo->prepare( + 'INSERT INTO budget_items + (month, category_id, name, planned_amount, due_date, recurrence, series_key, notes) + VALUES + (:month, :category_id, :name, :planned_amount, :due_date, :recurrence, :series_key, :notes)' + ); + $statement->execute([ + 'month' => $month, + 'category_id' => $categoryId, + 'name' => $bill['name'], + 'planned_amount' => $bill['amount'], + 'due_date' => $bill['due_date'], + 'recurrence' => $bill['recurrence'], + 'series_key' => $seriesKey, + 'notes' => 'Created during onboarding', + ]); + $itemId = (int) $this->pdo->lastInsertId(); + + if (!$bill['paid_now']) { + continue; + } + $statement = $this->pdo->prepare( + 'INSERT INTO transactions + (month, budget_item_id, category_id, merchant, amount, transacted_on, dead_pull_amount, notes) + VALUES + (:month, :budget_item_id, :category_id, :merchant, :amount, :transacted_on, :dead_pull, :notes)' + ); + $statement->execute([ + 'month' => $month, + 'budget_item_id' => $itemId, + 'category_id' => $categoryId, + 'merchant' => $bill['name'], + 'amount' => $bill['amount'], + 'transacted_on' => date('Y-m-d'), + 'dead_pull' => $bill['dead_pull'], + 'notes' => 'onboarding', + ]); + $transactionId = (int) $this->pdo->lastInsertId(); + $paidCount++; + + if ((float) $bill['dead_pull'] > 0) { + $this->insertDeadEntry( + $month, + 'transaction_pull', + (float) $bill['dead_pull'], + 'transaction', + $transactionId, + 'Automatic dormant transfer for ' . $bill['name'] + ); + } + } + + if ($deadAmount > 0) { + $this->insertDeadEntry( + $month, + 'manual_deposit', + $deadAmount, + 'onboarding', + $planId, + 'Dormant reserve created during onboarding' + ); + } + + $this->settings->set('onboarding.completed', true); + $this->settings->set('onboarding.completed_at', date(DATE_ATOM)); + $this->pdo->commit(); + } catch (\Throwable $exception) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $exception; + } + + return [ + 'bills' => count($normalizedBills), + 'paid' => $paidCount, + 'categories' => count($createdCategories), + 'reserved' => $deadAmount, + ]; + } + + private function upsertPlan(string $month, float $cash): int + { + $statement = $this->pdo->prepare('SELECT id FROM monthly_plans WHERE month = :month'); + $statement->execute(['month' => $month]); + $planId = $statement->fetchColumn(); + if ($planId !== false) { + $statement = $this->pdo->prepare( + 'UPDATE monthly_plans + SET starting_income = :cash, rollover_in = 0, rolling_enabled = 1, + rollover_source_month = NULL, notes = :notes, updated_at = CURRENT_TIMESTAMP + WHERE id = :id' + ); + $statement->execute([ + 'cash' => $cash, + 'notes' => 'Initial balance captured during onboarding', + 'id' => $planId, + ]); + return (int) $planId; + } + + $statement = $this->pdo->prepare( + 'INSERT INTO monthly_plans + (month, starting_income, rollover_in, rolling_enabled, rollover_source_month, notes) + VALUES + (:month, :cash, 0, 1, NULL, :notes)' + ); + $statement->execute([ + 'month' => $month, + 'cash' => $cash, + 'notes' => 'Initial balance captured during onboarding', + ]); + return (int) $this->pdo->lastInsertId(); + } + + private function findOrCreateCategory(string $name): int + { + $statement = $this->pdo->prepare('SELECT id FROM categories WHERE LOWER(name) = LOWER(:name) LIMIT 1'); + $statement->execute(['name' => $name]); + $id = $statement->fetchColumn(); + if ($id !== false) { + $reactivate = $this->pdo->prepare('UPDATE categories SET active = 1 WHERE id = :id'); + $reactivate->execute(['id' => $id]); + return (int) $id; + } + + $statement = $this->pdo->prepare( + 'INSERT INTO categories (name, color, monthly_limit, active) VALUES (:name, :color, 0, 1)' + ); + $statement->execute([ + 'name' => $name, + 'color' => $this->categoryColor($name), + ]); + return (int) $this->pdo->lastInsertId(); + } + + private function categoryColor(string $name): string + { + if (isset(self::COLORS[$name])) { + return self::COLORS[$name]; + } + $palette = ['#8b5cf6', '#49a8ff', '#ff5b9d', '#49e8a4', '#ffad5c', '#ff6473']; + return $palette[abs((int) crc32(strtolower($name))) % count($palette)]; + } + + private function automaticTransactionPull(float $amount): float + { + $mode = (string) $this->settings->get('dead.transaction_mode', 'fixed'); + $value = max(0, (float) $this->settings->get('dead.transaction_value', 0)); + $pull = $mode === 'percent' ? $amount * $value / 100 : $value; + return round(min($amount, max(0, $pull)), 2); + } + + private function insertDeadEntry( + string $month, + string $kind, + float $amount, + ?string $sourceType, + ?int $sourceId, + string $notes + ): void { + $statement = $this->pdo->prepare( + 'INSERT INTO dead_account_entries (month, kind, amount, source_type, source_id, notes) + VALUES (:month, :kind, :amount, :source_type, :source_id, :notes)' + ); + $statement->execute([ + 'month' => $month, + 'kind' => $kind, + 'amount' => round($amount, 2), + 'source_type' => $sourceType, + 'source_id' => $sourceId, + 'notes' => $notes, + ]); + } + + private function dateIsInMonth(string $date, string $month): bool + { + if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches)) { + return false; + } + return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) + && substr($date, 0, 7) === $month; + } + + private function truncate(string $value, int $length): string + { + return function_exists('mb_substr') ? mb_substr($value, 0, $length) : substr($value, 0, $length); + } +} diff --git a/app/Settings.php b/app/Settings.php index e35fb1f..8e5e01f 100644 --- a/app/Settings.php +++ b/app/Settings.php @@ -33,6 +33,7 @@ final class Settings 'mailgun.region' => 'us', 'mailgun.from' => 'Budget Planner ', 'security.2fa_required' => false, + 'security.2fa_method' => 'totp', ]; public function __construct(private PDO $pdo) @@ -52,6 +53,13 @@ final class Settings 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); diff --git a/app/bootstrap.php b/app/bootstrap.php index f4202f4..ef90834 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Budget\Auth; use Budget\BudgetService; use Budget\Database; +use Budget\OnboardingService; use Budget\Settings; const APP_ROOT = __DIR__ . '/..'; @@ -36,3 +37,4 @@ $settings = new Settings($pdo); date_default_timezone_set((string) $settings->get('app.timezone', 'America/New_York')); $auth = new Auth($pdo, $settings); $budget = new BudgetService($pdo, $settings); +$onboarding = new OnboardingService($pdo, $settings); diff --git a/public/assets/app.css b/public/assets/app.css index bc5c698..77586f7 100644 --- a/public/assets/app.css +++ b/public/assets/app.css @@ -656,13 +656,106 @@ td > small { margin-top: 3px; font-size: .53rem; } .auth-orbit .orbit-two { inset: 78px; border-color: rgba(255, 91, 157, .14); } .auth-orbit .orbit-three { inset: 120px; border-color: rgba(73, 232, 164, .14); } .auth-orbit strong { position: absolute; inset: 0; display: grid; place-items: center; color: var(--green); font-size: 2.6rem; text-shadow: 0 0 28px rgba(73, 232, 164, .35); } -.auth-panel { display: grid; place-items: center; padding: 32px; background: #0b0e16; } +.auth-panel { position: relative; display: grid; place-items: center; padding: 32px; background: #0b0e16; } +.auth-theme-toggle { position: absolute; z-index: 2; top: 20px; right: 20px; } .auth-panel .flash { width: min(420px, 100%); margin: 0 0 13px; } .auth-card { width: min(420px, 100%); padding: 30px; border: 1px solid rgba(255, 255, 255, .08); border-radius: 19px; background: #121621; box-shadow: 0 28px 75px rgba(0, 0, 0, .32); } .auth-card h2 { margin-bottom: 7px; font-size: 1.55rem; } .auth-card > .muted { margin-bottom: 22px; } .auth-card input, .auth-card select { background: #191e2c; } +.auth-info { padding: 12px 13px; border: 1px solid rgba(73, 168, 255, .16); border-radius: 10px; background: rgba(73, 168, 255, .055); } +.auth-info strong, .auth-info small { display: block; } +.auth-info strong { font-size: .66rem; } +.auth-info small { margin-top: 3px; font-size: .54rem; line-height: 1.5; } .otp-input { font-size: 1.45rem; letter-spacing: .38em; text-align: center; } +[data-theme="light"] .auth-body { color: var(--text); background: #e9edf4; } +[data-theme="light"] .auth-panel { background: #f4f6fa; } +[data-theme="light"] .auth-card { border-color: var(--border); background: #fff; box-shadow: var(--shadow); } +[data-theme="light"] .auth-card input, [data-theme="light"] .auth-card select { background: var(--surface-2); } + +.onboarding-body { min-height: 100vh; background: radial-gradient(circle at 50% -10%, rgba(139, 92, 246, .17), transparent 34rem), var(--bg); } +.onboarding-shell { min-height: 100vh; } +.onboarding-header { + display: flex; + min-height: 86px; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 16px clamp(20px, 4vw, 58px); + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 84%, transparent); + backdrop-filter: blur(22px); +} +.onboarding-brand { padding: 0; } +.onboarding-account { display: flex; align-items: center; gap: 6px; } +.onboarding-account > span { margin-right: 7px; text-align: right; } +.onboarding-account small, .onboarding-account strong { display: block; } +.onboarding-account small { font-size: .5rem; } +.onboarding-account strong { max-width: 240px; overflow: hidden; font-size: .63rem; text-overflow: ellipsis; white-space: nowrap; } +.onboarding-main { width: min(1120px, calc(100% - 36px)); margin: 0 auto; padding: clamp(42px, 6vw, 76px) 0 55px; } +.onboarding-intro { max-width: 800px; margin: 0 auto 30px; text-align: center; } +.onboarding-intro h1 { font-size: clamp(2.1rem, 5vw, 4.35rem); line-height: 1; letter-spacing: -.065em; } +.onboarding-intro > p:last-child { max-width: 660px; margin: 17px auto 0; color: var(--muted); font-size: .82rem; line-height: 1.7; } +.onboarding-progress { position: relative; display: grid; max-width: 760px; margin: 0 auto 27px; grid-template-columns: repeat(4, 1fr); } +.onboarding-progress::before { position: absolute; height: 1px; left: 12.5%; right: 12.5%; top: 16px; background: var(--border-strong); content: ""; } +.onboarding-progress > div { position: relative; z-index: 1; display: grid; justify-items: center; gap: 6px; color: var(--muted-2); text-align: center; } +.onboarding-progress span { display: grid; width: 33px; height: 33px; place-items: center; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--bg); font-size: .6rem; font-weight: 850; } +.onboarding-progress strong { font-size: .56rem; } +.onboarding-progress > div.active, .onboarding-progress > div.complete { color: var(--text); } +.onboarding-progress > div.active span { border-color: var(--purple-light); color: #fff; background: var(--purple); box-shadow: 0 0 22px rgba(139, 92, 246, .32); } +.onboarding-progress > div.complete span { border-color: rgba(73, 232, 164, .44); color: var(--green); background: rgba(73, 232, 164, .08); } +.onboarding-form { max-width: 980px; min-height: 500px; margin: 0 auto; padding: clamp(24px, 4vw, 46px); overflow: hidden; } +.onboarding-step { animation: onboarding-in .24s ease both; } +@keyframes onboarding-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +.step-copy { display: grid; grid-template-columns: 54px 1fr; gap: 16px; padding-bottom: 25px; border-bottom: 1px solid var(--border); } +.step-number { display: grid; width: 49px; height: 49px; place-items: center; border: 1px solid rgba(139, 92, 246, .26); border-radius: 14px; color: var(--purple-light); background: rgba(139, 92, 246, .08); font-size: .68rem; font-weight: 900; } +.step-copy h2 { font-size: clamp(1.35rem, 2.5vw, 2rem); } +.step-copy p:last-child { max-width: 720px; margin: 8px 0 0; color: var(--muted); font-size: .72rem; } +.hero-money-field, .bill-count-field { max-width: 540px; margin: 42px auto 34px; text-align: center; } +.hero-money-field > span, .bill-count-field > span { font-size: .65rem; text-transform: uppercase; letter-spacing: .09em; } +.hero-money-field .money-input { margin-top: 10px; } +.hero-money-field .money-input > span { left: 18px; font-size: 1.1rem; } +.hero-money-field input { min-height: 76px; padding: 10px 20px 10px 43px; border-radius: 15px; font-size: 2.15rem; font-weight: 820; letter-spacing: -.04em; text-align: center; } +.hero-money-field small, .bill-count-field small { margin-top: 8px; font-size: .58rem; font-weight: 550; } +.bill-count-field { max-width: 260px; } +.bill-count-field input { min-height: 76px; margin-top: 10px; border-radius: 15px; font-size: 2.15rem; font-weight: 820; text-align: center; } +.onboarding-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 32px; padding-top: 23px; border-top: 1px solid var(--border); } +.onboarding-actions .button { min-width: 145px; } +.onboarding-check-all { margin: 22px 0 15px; } +.onboarding-bills { display: grid; gap: 13px; } +.onboarding-bill-card { padding: 18px; border: 1px solid var(--border); border-radius: 14px; background: rgba(255, 255, 255, .015); } +.bill-card-heading { display: flex; align-items: center; gap: 11px; margin-bottom: 15px; } +.bill-index { display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; border-radius: 9px; color: var(--blue); background: rgba(73, 168, 255, .09); font-size: .62rem; font-weight: 900; } +.bill-card-heading h3 { margin: 0; font-size: .78rem; } +.bill-card-heading p { margin: 2px 0 0; color: var(--muted); font-size: .53rem; } +.bill-fields { display: grid; grid-template-columns: 1.25fr .72fr .85fr .78fr; gap: 11px; } +.bill-category-field { grid-column: span 2; } +.bill-custom-category { grid-column: span 2; } +.bill-paid-row { margin-top: 12px; } +.onboarding-preference-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; margin-top: 22px; } +.onboarding-option-panel { padding: 19px; border: 1px solid var(--border); border-radius: 14px; background: rgba(255, 255, 255, .016); } +.option-heading { display: flex; gap: 12px; margin-bottom: 17px; } +.option-heading h3 { margin: 0; font-size: .78rem; } +.option-heading p { margin: 4px 0 0; color: var(--muted); font-size: .57rem; line-height: 1.5; } +.category-chip-grid { display: flex; flex-wrap: wrap; gap: 7px; } +.category-chip-grid label { position: relative; display: block; cursor: pointer; } +.category-chip-grid input { position: absolute; opacity: 0; pointer-events: none; } +.category-chip-grid span { display: block; padding: 7px 10px; border: 1px solid var(--border); border-radius: 20px; color: var(--muted); background: var(--surface-2); font-size: .58rem; font-weight: 750; } +.category-chip-grid input:checked + span { border-color: rgba(73, 168, 255, .4); color: var(--blue); background: rgba(73, 168, 255, .09); } +.dead-option > label:last-child { margin-top: 12px; } +.onboarding-review { display: grid; margin-top: 18px; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; background: var(--surface-2); } +.onboarding-review > div { padding: 15px; border-left: 1px solid var(--border); } +.onboarding-review > div:first-child { border-left: 0; } +.onboarding-review span, .onboarding-review strong { display: block; } +.onboarding-review span { color: var(--muted); font-size: .54rem; text-transform: uppercase; } +.onboarding-review strong { margin-top: 4px; font-size: .9rem; } +.onboarding-review .review-remaining { background: rgba(73, 232, 164, .055); } +.onboarding-review .review-remaining strong { color: var(--green); } +.onboarding-review .review-remaining.negative { background: rgba(255, 100, 115, .06); } +.onboarding-review .review-remaining.negative strong { color: var(--red); } +.onboarding-warning { margin: 10px 0 0; color: var(--red); font-size: .62rem; text-align: right; } +.onboarding-flash { width: min(980px, calc(100% - 36px)); margin: 18px auto -16px; } +.onboarding-footer { display: flex; width: min(1120px, calc(100% - 36px)); justify-content: space-between; gap: 20px; margin: 0 auto; padding: 20px 0 26px; border-top: 1px solid var(--border); color: var(--muted-2); font-size: .56rem; } @media (max-width: 1220px) { .metric-grid, .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -736,6 +829,15 @@ td > small { margin-top: 3px; font-size: .53rem; } .data-table td::before { color: var(--muted); font-size: .55rem; font-weight: 800; text-transform: uppercase; content: attr(data-label); } .data-table td.table-actions { display: flex; position: absolute; top: 10px; right: 0; width: auto; } .data-table td.table-actions::before { display: none; } + .onboarding-main { width: min(100% - 28px, 1120px); padding-top: 35px; } + .onboarding-intro h1 { font-size: 2.35rem; } + .onboarding-form { padding: 23px; } + .bill-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .bill-category-field, .bill-custom-category { grid-column: span 1; } + .onboarding-preference-grid { grid-template-columns: 1fr; } + .onboarding-review { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .onboarding-review > div:nth-child(3) { border-left: 0; border-top: 1px solid var(--border); } + .onboarding-review > div:nth-child(4) { border-top: 1px solid var(--border); } } @media (max-width: 520px) { @@ -754,4 +856,26 @@ td > small { margin-top: 3px; font-size: .53rem; } .category-admin-list .money-input { grid-column: 2 / -1; } .auth-panel { padding: 17px; } .auth-card { padding: 23px; } + .onboarding-header { min-height: 72px; padding: 12px 15px; } + .onboarding-header .brand-copy { display: none; } + .onboarding-account > span { display: none; } + .onboarding-intro { margin-bottom: 23px; } + .onboarding-intro h1 { font-size: 2rem; } + .onboarding-intro > p:last-child { font-size: .72rem; } + .onboarding-progress strong { display: none; } + .onboarding-progress::before { left: 12%; right: 12%; } + .onboarding-form { min-height: 0; padding: 19px; border-radius: 15px; } + .step-copy { grid-template-columns: 1fr; } + .step-number { width: 40px; height: 40px; } + .hero-money-field { margin: 30px auto 24px; } + .hero-money-field input { min-height: 67px; font-size: 1.75rem; } + .bill-fields { grid-template-columns: 1fr; } + .bill-category-field, .bill-custom-category { grid-column: 1; } + .onboarding-actions { align-items: stretch; flex-direction: column-reverse; } + .onboarding-actions > span { display: none; } + .onboarding-actions .button { width: 100%; } + .onboarding-review { grid-template-columns: 1fr; } + .onboarding-review > div { border-top: 1px solid var(--border); border-left: 0; } + .onboarding-review > div:first-child { border-top: 0; } + .onboarding-footer { align-items: center; flex-direction: column; text-align: center; } } diff --git a/public/assets/app.js b/public/assets/app.js index 1b8d6fc..3830b34 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -95,6 +95,178 @@ syncRestoreFields(); }); + const onboardingForm = document.querySelector('[data-onboarding-form]'); + if (onboardingForm) { + const steps = [...onboardingForm.querySelectorAll('[data-onboarding-step]')]; + const progress = [...document.querySelectorAll('[data-onboarding-progress]')]; + const cashInput = onboardingForm.querySelector('[data-current-cash]'); + const billCountInput = onboardingForm.querySelector('[data-bill-count]'); + const billRows = onboardingForm.querySelector('[data-bill-rows]'); + const billTemplate = document.querySelector('#onboarding-bill-template'); + const markAllPaid = onboardingForm.querySelector('[data-mark-all-paid]'); + const enableDead = onboardingForm.querySelector('[data-enable-onboarding-dead]'); + const deadFields = onboardingForm.querySelector('[data-onboarding-dead-fields]'); + const deadAmount = onboardingForm.querySelector('[data-dead-amount]'); + const submitButton = onboardingForm.querySelector('[data-onboarding-submit]'); + const warning = onboardingForm.querySelector('[data-onboarding-warning]'); + const symbol = onboardingForm.dataset.symbol || '$'; + let currentStep = 1; + let generatedCount = -1; + + const money = (value) => `${value < 0 ? '-' : ''}${symbol}${Math.abs(value).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + + const showStep = (target) => { + currentStep = target; + steps.forEach((step) => { + const active = Number(step.dataset.onboardingStep) === target; + step.hidden = !active; + step.classList.toggle('active', active); + }); + progress.forEach((item) => { + const number = Number(item.dataset.onboardingProgress); + item.classList.toggle('active', number === target); + item.classList.toggle('complete', number < target); + }); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const validateCurrentStep = () => { + const step = steps.find((item) => Number(item.dataset.onboardingStep) === currentStep); + const fields = [...step.querySelectorAll('input, select, textarea')].filter((field) => !field.disabled); + for (const field of fields) { + if (!field.checkValidity()) { + field.reportValidity(); + return false; + } + } + return true; + }; + + const paidInputs = () => [...billRows.querySelectorAll('[data-bill-field="paid_now"]')]; + const syncPaidMaster = () => { + const inputs = paidInputs(); + const checked = inputs.filter((input) => input.checked).length; + markAllPaid.checked = inputs.length > 0 && checked === inputs.length; + markAllPaid.indeterminate = checked > 0 && checked < inputs.length; + }; + + const updateReview = () => { + const cash = Number(cashInput.value) || 0; + let paid = 0; + billRows.querySelectorAll('.onboarding-bill-card').forEach((card) => { + if (card.querySelector('[data-bill-field="paid_now"]').checked) { + paid += Number(card.querySelector('[data-bill-field="amount"]').value) || 0; + } + }); + const reserved = enableDead.checked ? Number(deadAmount.value) || 0 : 0; + const remaining = cash - paid - reserved; + onboardingForm.querySelector('[data-review-cash]').textContent = money(cash); + onboardingForm.querySelector('[data-review-paid]').textContent = money(paid); + onboardingForm.querySelector('[data-review-dead]').textContent = money(reserved); + onboardingForm.querySelector('[data-review-remaining]').textContent = money(remaining); + onboardingForm.querySelector('.review-remaining').classList.toggle('negative', remaining < 0); + warning.hidden = remaining >= 0; + submitButton.disabled = remaining < 0; + }; + + const generateBillRows = () => { + const count = Math.max(0, Math.min(50, Number.parseInt(billCountInput.value, 10) || 0)); + billCountInput.value = String(count); + if (generatedCount === count) return count; + generatedCount = count; + billRows.replaceChildren(); + for (let index = 0; index < count; index++) { + const fragment = billTemplate.content.cloneNode(true); + const card = fragment.querySelector('.onboarding-bill-card'); + card.querySelector('[data-bill-index]').textContent = String(index + 1).padStart(2, '0'); + const title = card.querySelector('[data-bill-title]'); + title.textContent = `Bill ${index + 1}`; + card.querySelectorAll('[data-bill-field]').forEach((field) => { + const key = field.dataset.billField; + field.name = `bills[${index}][${key}]`; + }); + const nameInput = card.querySelector('[data-bill-field="name"]'); + const amountInput = card.querySelector('[data-bill-field="amount"]'); + const paidInput = card.querySelector('[data-bill-field="paid_now"]'); + const categoryInput = card.querySelector('[data-bill-field="category"]'); + const customWrap = card.querySelector('[data-custom-category]'); + const customInput = card.querySelector('[data-bill-field="custom_category"]'); + + nameInput.addEventListener('input', () => { + title.textContent = nameInput.value.trim() || `Bill ${index + 1}`; + }); + amountInput.addEventListener('input', updateReview); + paidInput.addEventListener('change', () => { + syncPaidMaster(); + updateReview(); + }); + categoryInput.addEventListener('change', () => { + const custom = categoryInput.value === '__custom__'; + customWrap.hidden = !custom; + customInput.required = custom; + if (!custom) customInput.value = ''; + }); + paidInput.checked = markAllPaid.checked; + billRows.append(fragment); + } + syncPaidMaster(); + updateReview(); + return count; + }; + + onboardingForm.querySelectorAll('[data-onboarding-next]').forEach((button) => { + button.addEventListener('click', () => { + if (!validateCurrentStep()) return; + if (currentStep === 2) { + const count = generateBillRows(); + showStep(count === 0 ? 4 : 3); + updateReview(); + return; + } + showStep(Math.min(4, currentStep + 1)); + updateReview(); + }); + }); + + onboardingForm.querySelectorAll('[data-onboarding-back]').forEach((button) => { + button.addEventListener('click', () => { + if (currentStep === 4 && generatedCount === 0) { + showStep(2); + return; + } + showStep(Math.max(1, currentStep - 1)); + }); + }); + + markAllPaid.addEventListener('change', () => { + paidInputs().forEach((input) => { + input.checked = markAllPaid.checked; + }); + markAllPaid.indeterminate = false; + updateReview(); + }); + enableDead.addEventListener('change', () => { + deadFields.hidden = !enableDead.checked; + deadAmount.required = enableDead.checked; + if (!enableDead.checked) deadAmount.value = ''; + updateReview(); + }); + cashInput.addEventListener('input', updateReview); + deadAmount.addEventListener('input', updateReview); + onboardingForm.addEventListener('submit', (event) => { + generateBillRows(); + updateReview(); + if (submitButton.disabled) { + event.preventDefault(); + warning.hidden = false; + } + }); + updateReview(); + } + function drawTrendChart(canvas) { let data; try { diff --git a/public/index.php b/public/index.php index b94d985..a65c83b 100644 --- a/public/index.php +++ b/public/index.php @@ -61,7 +61,7 @@ function redirect_to(string $route, array $parameters = []): never exit; } -function render_view(string $view, array $data = [], bool $authLayout = false): never +function render_view(string $view, array $data = [], string|bool $layout = false): never { global $settings, $auth; extract($data, EXTR_SKIP); @@ -69,10 +69,20 @@ function render_view(string $view, array $data = [], bool $authLayout = false): ob_start(); require APP_ROOT . '/views/' . $view . '.php'; $content = (string) ob_get_clean(); - require APP_ROOT . '/views/' . ($authLayout ? 'auth-layout.php' : 'layout.php'); + $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'))); @@ -93,12 +103,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 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'); + $auth->setup((string) $email, $password, false); + Support::flash('success', 'Account created. Let’s build your starting plan.'); + redirect_to('welcome'); } if ($action === 'login') { @@ -133,6 +140,44 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 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, @@ -363,13 +408,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } $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 && empty($user['totp_secret'])) { + 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.'); @@ -434,7 +486,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 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'); + $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] : []); } } @@ -449,13 +504,35 @@ if ($auth->needsSetup()) { if (!$auth->check()) { if ($route === 'two-factor' && isset($_SESSION['pending_2fa_user'])) { - render_view('two-factor', ['title' => 'Two-factor verification'], true); + 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') { diff --git a/scripts/onboarding-test.php b/scripts/onboarding-test.php new file mode 100644 index 0000000..3d3bcf7 --- /dev/null +++ b/scripts/onboarding-test.php @@ -0,0 +1,68 @@ +setup('planner-test@example.com', 'TestingPlanner2026!', false); +$assert(($auth->user()['email'] ?? null) === 'planner-test@example.com', 'New account session did not retain the administrator ID.'); + +$settings->set('dead.transaction_mode', 'fixed'); +$settings->set('dead.transaction_value', 1); +$settings->set('onboarding.completed', false); + +$result = $onboarding->complete($month, 1000, [ + [ + 'name' => 'Electric', + 'amount' => 80, + 'due_date' => $today, + 'recurrence' => 'monthly', + 'category' => 'Utilities', + 'paid_now' => '1', + ], + [ + 'name' => 'Internet', + 'amount' => 55, + 'due_date' => $today, + 'recurrence' => 'monthly', + 'category' => 'Utilities', + ], +], ['Netflix', 'Luxury'], 100); + +$summary = $budget->summary($month); +$items = $budget->budgetItems($month); +$transactions = $budget->transactions($month); +$categories = array_column($budget->categories(), 'name'); + +$assert((bool) $settings->get('onboarding.completed', false), 'Onboarding was not marked complete.'); +$assert($result === ['bills' => 2, 'paid' => 1, 'categories' => 3, 'reserved' => 100.0], 'Onboarding result summary is incorrect.'); +$assert(count($items) === 2, 'Bills were not converted into budget items.'); +$assert(count($transactions) === 1, 'The paid bill did not create one transaction.'); +$assert($transactions[0]['notes'] === 'onboarding', 'The onboarding transaction note is missing.'); +$assert($transactions[0]['transacted_on'] === $today, 'The onboarding transaction date is incorrect.'); +$assert(abs((float) $summary['remaining'] - 819) < 0.001, 'Onboarding remaining cash is incorrect.'); +$assert(abs($budget->deadBalance() - 101) < 0.001, 'Dormant onboarding transfers are incorrect.'); +$assert(in_array('Utilities', $categories, true), 'Consolidated Utilities category was not created.'); +$assert(in_array('Netflix', $categories, true) && in_array('Luxury', $categories, true), 'Additional categories were not created.'); + +echo "Neon Ledger onboarding test passed.\n"; diff --git a/views/auth-layout.php b/views/auth-layout.php index 75848da..710a6ed 100644 --- a/views/auth-layout.php +++ b/views/auth-layout.php @@ -1,12 +1,16 @@ -get('app.name', 'Neon Ledger'); ?> +get('app.name', 'Neon Ledger'); +$defaultTheme = (string) $settings->get('theme.default', 'dark'); +?> - + - + <?= h($title ?? 'Welcome') ?> · <?= h($appName) ?> +
@@ -23,11 +27,13 @@
+
!
+ diff --git a/views/onboarding-layout.php b/views/onboarding-layout.php new file mode 100644 index 0000000..98c0f21 --- /dev/null +++ b/views/onboarding-layout.php @@ -0,0 +1,49 @@ +get('app.name', 'Neon Ledger'); +$defaultTheme = (string) $settings->get('theme.default', 'dark'); +?> + + + + + + + + <?= h($title ?? 'Welcome') ?> · <?= h($appName) ?> + + + + +
+
+
+ N + Personal finance planner +
+ +
+ + +
+ +

+ +
+ + +
+
+ Private by default + SQLite powered · Your data stays under your control +
+
+ + + diff --git a/views/onboarding.php b/views/onboarding.php new file mode 100644 index 0000000..115c6a7 --- /dev/null +++ b/views/onboarding.php @@ -0,0 +1,184 @@ + +
+

A clean starting point

+

Let’s plan from what you have now.

+

The day of the month does not matter. We will use today’s available money as the starting point for , then carry any unused remainder forward.

+
+ +
+ $label): ?> +
+ +
+ +
+ +
+ + + +
+
+ 01 +
+

Start with the truth

+

How much money do you have in total right now?

+

Include the cash you consider usable across checking, cash, and other active accounts. This is your manual starting point, not a forecast.

+
+
+ +
+ + +
+
+ + + + + + +
+ + diff --git a/views/settings.php b/views/settings.php index 872fac4..52d9d1b 100644 --- a/views/settings.php +++ b/views/settings.php @@ -73,7 +73,15 @@

Administrator protection

Security

- +
+ + +

Administrator: · account created

@@ -83,10 +91,12 @@
-
- - -
+ +
+ + +
+ module('alerts')): ?>
diff --git a/views/setup.php b/views/setup.php index c4ed602..fa6c616 100644 --- a/views/setup.php +++ b/views/setup.php @@ -1,14 +1,17 @@
-

First run

-

Create the administrator

-

This account controls the planner, its modules, exports, and alert settings.

+

Welcome to Neon Ledger

+

Create your private planner

+

Plan from the money you actually have, build monthly bill targets, and carry unused funds safely into the next month.

- - +
+ Protection and reminders come next + Optional authenticator or email-delivered 2FA and Mailgun bill alerts are available in Configuration after onboarding. +
+
diff --git a/views/two-factor.php b/views/two-factor.php index 5e01dcf..43d24c1 100644 --- a/views/two-factor.php +++ b/views/two-factor.php @@ -1,7 +1,9 @@

Second step

Authentication code

-

Enter the current six-digit code from your authenticator.

+