- 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.
This commit is contained in:
Ty Clifford
2026-06-15 14:51:44 -04:00
parent 51ee4ecb5a
commit 6686388834
15 changed files with 1159 additions and 32 deletions
+72 -5
View File
@@ -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',
'<div style="font-family:system-ui,sans-serif;max-width:520px;margin:auto;padding:28px">'
. '<h1 style="font-size:22px">Your sign-in code</h1>'
. '<p>Enter this code to finish signing in. It expires in 10 minutes.</p>'
. '<p style="font-size:32px;font-weight:800;letter-spacing:8px">' . $code . '</p>'
. '<p style="color:#687187">If you did not try to sign in, you can ignore this email.</p>'
. '</div>'
);
$_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']
);
}
}
+1 -1
View File
@@ -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];
}
+355
View File
@@ -0,0 +1,355 @@
<?php
declare(strict_types=1);
namespace Budget;
use PDO;
use RuntimeException;
final class OnboardingService
{
private const COLORS = [
'Utilities' => '#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<array<string, mixed>> $bills
* @param list<string> $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);
}
}
+8
View File
@@ -33,6 +33,7 @@ final class Settings
'mailgun.region' => 'us',
'mailgun.from' => 'Budget Planner <budget@example.com>',
'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);
+2
View File
@@ -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);