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 @@
+ = icon('sun') ?>
! = h($flash['message']) ?>
= $content ?>
+
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) ?>
+
+
+
+
+
+
+
+
+
+
= $flash['type'] === 'success' ? '✓' : '!' ?>
+
= h($flash['message']) ?>
+
= icon('close') ?>
+
+
+
+
= $content ?>
+
+
+
+
+
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 = h($monthLabel) ?>, then carry any unused remainder forward.
+
+
+
+ $label): ?>
+
+ = $index + 1 ?> = h($label) ?>
+
+
+
+
+
+
+
+
+
+
1
+
Bill 1 Creates a progress-tracked budget target.
+
+
+
+
+ This bill was paid today Deduct the amount now and add an “onboarding” transaction to history.
+
+
+
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
- >Require two-factor authentication Uses any standard TOTP authenticator app.
+
+ Two-factor method
+
+ >Authenticator app
+ >Email through Mailgun
+
+
+ >Require two-factor authentication Email delivery requires complete Mailgun settings above.
+
Administrator: = h($user['email']) ?> · account created = h(date('M j, Y', strtotime($user['created_at']))) ?>
@@ -83,10 +91,12 @@
-
+
+
+
module('alerts')): ?>