- 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:
+72
-5
@@ -31,9 +31,11 @@ final class Auth
|
|||||||
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||||
'secret' => $secret,
|
'secret' => $secret,
|
||||||
]);
|
]);
|
||||||
|
$userId = (int) $this->pdo->lastInsertId();
|
||||||
$this->settings->set('alerts.email', strtolower(trim($email)));
|
$this->settings->set('alerts.email', strtolower(trim($email)));
|
||||||
$this->settings->set('security.2fa_required', $enableTwoFactor);
|
$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);
|
session_regenerate_id(true);
|
||||||
return $secret;
|
return $secret;
|
||||||
}
|
}
|
||||||
@@ -48,10 +50,16 @@ final class Auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ((bool) $this->settings->get('security.2fa_required', false)) {
|
if ((bool) $this->settings->get('security.2fa_required', false)) {
|
||||||
if (empty($user['totp_secret'])) {
|
$method = (string) $this->settings->get('security.2fa_method', 'totp');
|
||||||
return 'unconfigured_2fa';
|
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';
|
return '2fa';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,13 +73,16 @@ final class Auth
|
|||||||
if ($userId === 0) {
|
if ($userId === 0) {
|
||||||
return false;
|
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 = $this->pdo->prepare('SELECT totp_secret FROM users WHERE id = :id');
|
||||||
$statement->execute(['id' => $userId]);
|
$statement->execute(['id' => $userId]);
|
||||||
$secret = (string) $statement->fetchColumn();
|
$secret = (string) $statement->fetchColumn();
|
||||||
if ($secret === '' || !Totp::verify($secret, $code)) {
|
if ($secret === '' || !Totp::verify($secret, $code)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
unset($_SESSION['pending_2fa_user']);
|
unset($_SESSION['pending_2fa_user'], $_SESSION['pending_2fa_method']);
|
||||||
$this->completeLogin($userId);
|
$this->completeLogin($userId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -111,4 +122,60 @@ final class Auth
|
|||||||
$_SESSION['user_id'] = $userId;
|
$_SESSION['user_id'] = $userId;
|
||||||
session_regenerate_id(true);
|
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
@@ -52,7 +52,7 @@ final class Mailgun
|
|||||||
preg_match('/\s(\d{3})\s/', $statusLine, $matches);
|
preg_match('/\s(\d{3})\s/', $statusLine, $matches);
|
||||||
$status = (int) ($matches[1] ?? 500);
|
$status = (int) ($matches[1] ?? 500);
|
||||||
if ($response === false || $status >= 400) {
|
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];
|
return ['status' => $status, 'body' => $response];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ final class Settings
|
|||||||
'mailgun.region' => 'us',
|
'mailgun.region' => 'us',
|
||||||
'mailgun.from' => 'Budget Planner <budget@example.com>',
|
'mailgun.from' => 'Budget Planner <budget@example.com>',
|
||||||
'security.2fa_required' => false,
|
'security.2fa_required' => false,
|
||||||
|
'security.2fa_method' => 'totp',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(private PDO $pdo)
|
public function __construct(private PDO $pdo)
|
||||||
@@ -52,6 +53,13 @@ final class Settings
|
|||||||
return json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
|
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
|
public function set(string $key, mixed $value): void
|
||||||
{
|
{
|
||||||
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
use Budget\Auth;
|
use Budget\Auth;
|
||||||
use Budget\BudgetService;
|
use Budget\BudgetService;
|
||||||
use Budget\Database;
|
use Budget\Database;
|
||||||
|
use Budget\OnboardingService;
|
||||||
use Budget\Settings;
|
use Budget\Settings;
|
||||||
|
|
||||||
const APP_ROOT = __DIR__ . '/..';
|
const APP_ROOT = __DIR__ . '/..';
|
||||||
@@ -36,3 +37,4 @@ $settings = new Settings($pdo);
|
|||||||
date_default_timezone_set((string) $settings->get('app.timezone', 'America/New_York'));
|
date_default_timezone_set((string) $settings->get('app.timezone', 'America/New_York'));
|
||||||
$auth = new Auth($pdo, $settings);
|
$auth = new Auth($pdo, $settings);
|
||||||
$budget = new BudgetService($pdo, $settings);
|
$budget = new BudgetService($pdo, $settings);
|
||||||
|
$onboarding = new OnboardingService($pdo, $settings);
|
||||||
|
|||||||
+125
-1
@@ -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-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 .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-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-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 { 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 h2 { margin-bottom: 7px; font-size: 1.55rem; }
|
||||||
.auth-card > .muted { margin-bottom: 22px; }
|
.auth-card > .muted { margin-bottom: 22px; }
|
||||||
.auth-card input, .auth-card select { background: #191e2c; }
|
.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; }
|
.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) {
|
@media (max-width: 1220px) {
|
||||||
.metric-grid, .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.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::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 { display: flex; position: absolute; top: 10px; right: 0; width: auto; }
|
||||||
.data-table td.table-actions::before { display: none; }
|
.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) {
|
@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; }
|
.category-admin-list .money-input { grid-column: 2 / -1; }
|
||||||
.auth-panel { padding: 17px; }
|
.auth-panel { padding: 17px; }
|
||||||
.auth-card { padding: 23px; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,178 @@
|
|||||||
syncRestoreFields();
|
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) {
|
function drawTrendChart(canvas) {
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+88
-11
@@ -61,7 +61,7 @@ function redirect_to(string $route, array $parameters = []): never
|
|||||||
exit;
|
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;
|
global $settings, $auth;
|
||||||
extract($data, EXTR_SKIP);
|
extract($data, EXTR_SKIP);
|
||||||
@@ -69,10 +69,20 @@ function render_view(string $view, array $data = [], bool $authLayout = false):
|
|||||||
ob_start();
|
ob_start();
|
||||||
require APP_ROOT . '/views/' . $view . '.php';
|
require APP_ROOT . '/views/' . $view . '.php';
|
||||||
$content = (string) ob_get_clean();
|
$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;
|
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'));
|
$route = (string) ($_GET['route'] ?? ($auth->check() ? 'dashboard' : 'login'));
|
||||||
$month = Support::month((string) ($_GET['month'] ?? $_POST['month'] ?? date('Y-m')));
|
$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'] ?? '')) {
|
if (strlen($password) < 10 || $password !== (string) ($_POST['password_confirmation'] ?? '')) {
|
||||||
throw new RuntimeException('Use at least 10 characters and make sure both passwords match.');
|
throw new RuntimeException('Use at least 10 characters and make sure both passwords match.');
|
||||||
}
|
}
|
||||||
$secret = $auth->setup((string) $email, $password, isset($_POST['enable_2fa']));
|
$auth->setup((string) $email, $password, false);
|
||||||
if ($secret) {
|
Support::flash('success', 'Account created. Let’s build your starting plan.');
|
||||||
$_SESSION['new_totp_secret'] = $secret;
|
redirect_to('welcome');
|
||||||
}
|
|
||||||
Support::flash('success', 'Administrator account created. Your planner is ready.');
|
|
||||||
redirect_to($secret ? 'settings' : 'dashboard');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'login') {
|
if ($action === 'login') {
|
||||||
@@ -133,6 +140,44 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
exit;
|
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') {
|
if ($action === 'plan.save') {
|
||||||
$budget->savePlan(
|
$budget->savePlan(
|
||||||
$month,
|
$month,
|
||||||
@@ -363,13 +408,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$requireTwoFactor = isset($_POST['security_2fa_required']);
|
$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();
|
$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();
|
$secret = Totp::generateSecret();
|
||||||
$statement = $pdo->prepare('UPDATE users SET totp_secret = :secret, updated_at = CURRENT_TIMESTAMP WHERE id = :id');
|
$statement = $pdo->prepare('UPDATE users SET totp_secret = :secret, updated_at = CURRENT_TIMESTAMP WHERE id = :id');
|
||||||
$statement->execute(['secret' => $secret, 'id' => $auth->id()]);
|
$statement->execute(['secret' => $secret, 'id' => $auth->id()]);
|
||||||
$_SESSION['new_totp_secret'] = $secret;
|
$_SESSION['new_totp_secret'] = $secret;
|
||||||
}
|
}
|
||||||
|
$settings->set('security.2fa_method', $twoFactorMethod);
|
||||||
$settings->set('security.2fa_required', $requireTwoFactor);
|
$settings->set('security.2fa_required', $requireTwoFactor);
|
||||||
Support::audit($pdo, $auth->id(), 'settings.updated');
|
Support::audit($pdo, $auth->id(), 'settings.updated');
|
||||||
Support::flash('success', 'Planner configuration saved.');
|
Support::flash('success', 'Planner configuration saved.');
|
||||||
@@ -434,7 +486,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
throw new RuntimeException('Unknown action.');
|
throw new RuntimeException('Unknown action.');
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Support::flash('error', $exception->getMessage());
|
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] : []);
|
redirect_to($fallbackRoute, $auth->check() && $month ? ['month' => $month] : []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,13 +504,35 @@ if ($auth->needsSetup()) {
|
|||||||
|
|
||||||
if (!$auth->check()) {
|
if (!$auth->check()) {
|
||||||
if ($route === 'two-factor' && isset($_SESSION['pending_2fa_user'])) {
|
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);
|
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);
|
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)) {
|
if (in_array($route, ['export-json', 'export-html', 'export-pdf'], true)) {
|
||||||
$exporter = new ExportService($pdo, $settings, $budget);
|
$exporter = new ExportService($pdo, $settings, $budget);
|
||||||
if ($route === 'export-json') {
|
if ($route === 'export-json') {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$databasePath = sys_get_temp_dir() . '/neon-ledger-onboarding-test-' . getmypid() . '.sqlite';
|
||||||
|
putenv('BUDGET_SQLITE_PATH=' . $databasePath);
|
||||||
|
|
||||||
|
register_shutdown_function(static function () use ($databasePath): void {
|
||||||
|
foreach ([$databasePath, $databasePath . '-shm', $databasePath . '-wal'] as $file) {
|
||||||
|
if (is_file($file)) {
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
require __DIR__ . '/../app/bootstrap.php';
|
||||||
|
|
||||||
|
$assert = static function (bool $condition, string $message): void {
|
||||||
|
if (!$condition) {
|
||||||
|
throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$month = date('Y-m');
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
session_start();
|
||||||
|
$auth->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";
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
<?php $appName = (string) $settings->get('app.name', 'Neon Ledger'); ?>
|
<?php
|
||||||
|
$appName = (string) $settings->get('app.name', 'Neon Ledger');
|
||||||
|
$defaultTheme = (string) $settings->get('theme.default', 'dark');
|
||||||
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en" data-theme="<?= h($defaultTheme) ?>">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark light">
|
||||||
<title><?= h($title ?? 'Welcome') ?> · <?= h($appName) ?></title>
|
<title><?= h($title ?? 'Welcome') ?> · <?= h($appName) ?></title>
|
||||||
<link rel="stylesheet" href="/assets/app.css?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.css') ?>">
|
<link rel="stylesheet" href="/assets/app.css?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.css') ?>">
|
||||||
|
<script>document.documentElement.dataset.theme=localStorage.getItem('budget-theme')||<?= json_encode($defaultTheme) ?>;</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="auth-body">
|
<body class="auth-body">
|
||||||
<main class="auth-shell">
|
<main class="auth-shell">
|
||||||
@@ -23,11 +27,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="auth-panel">
|
<section class="auth-panel">
|
||||||
|
<button class="icon-control auth-theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark mode"><?= icon('sun') ?></button>
|
||||||
<?php foreach ($flashes as $flash): ?>
|
<?php foreach ($flashes as $flash): ?>
|
||||||
<div class="flash <?= h($flash['type']) ?>"><span>!</span><?= h($flash['message']) ?></div>
|
<div class="flash <?= h($flash['type']) ?>"><span>!</span><?= h($flash['message']) ?></div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?= $content ?>
|
<?= $content ?>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<script src="/assets/app.js?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.js') ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
$appName = (string) $settings->get('app.name', 'Neon Ledger');
|
||||||
|
$defaultTheme = (string) $settings->get('theme.default', 'dark');
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="<?= h($defaultTheme) ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<meta name="theme-color" content="#090b12">
|
||||||
|
<title><?= h($title ?? 'Welcome') ?> · <?= h($appName) ?></title>
|
||||||
|
<link rel="stylesheet" href="/assets/app.css?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.css') ?>">
|
||||||
|
<script>document.documentElement.dataset.theme=localStorage.getItem('budget-theme')||<?= json_encode($defaultTheme) ?>;</script>
|
||||||
|
</head>
|
||||||
|
<body class="onboarding-body">
|
||||||
|
<div class="onboarding-shell">
|
||||||
|
<header class="onboarding-header">
|
||||||
|
<div class="brand onboarding-brand">
|
||||||
|
<span class="brand-mark"><i></i><b>N</b></span>
|
||||||
|
<span class="brand-copy"><strong><?= h($appName) ?></strong><small>Personal finance planner</small></span>
|
||||||
|
</div>
|
||||||
|
<div class="onboarding-account">
|
||||||
|
<span><small>Signed in as</small><strong><?= h($user['email'] ?? '') ?></strong></span>
|
||||||
|
<button class="icon-control" type="button" data-theme-toggle aria-label="Toggle light and dark mode"><?= icon('sun') ?></button>
|
||||||
|
<form method="post">
|
||||||
|
<?= csrf_field() ?><input type="hidden" name="action" value="logout">
|
||||||
|
<button class="icon-control" type="submit" aria-label="Sign out"><?= icon('logout') ?></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<?php foreach ($flashes as $flash): ?>
|
||||||
|
<div class="flash onboarding-flash <?= h($flash['type']) ?>" role="status">
|
||||||
|
<span><?= $flash['type'] === 'success' ? '✓' : '!' ?></span>
|
||||||
|
<p><?= h($flash['message']) ?></p>
|
||||||
|
<button type="button" aria-label="Dismiss"><?= icon('close') ?></button>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<main class="onboarding-main"><?= $content ?></main>
|
||||||
|
<footer class="onboarding-footer">
|
||||||
|
<span>Private by default</span>
|
||||||
|
<span>SQLite powered · Your data stays under your control</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script src="/assets/app.js?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.js') ?>"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
$monthLabel = date('F Y', strtotime($month . '-01'));
|
||||||
|
$lastDay = date('Y-m-t', strtotime($month . '-01'));
|
||||||
|
$categoryOptions = [
|
||||||
|
'Utilities',
|
||||||
|
'Housing',
|
||||||
|
'Communications',
|
||||||
|
'Subscriptions',
|
||||||
|
'Insurance',
|
||||||
|
'Transportation',
|
||||||
|
'Debt',
|
||||||
|
'Food',
|
||||||
|
'Health',
|
||||||
|
'Childcare',
|
||||||
|
'Education',
|
||||||
|
];
|
||||||
|
$extraCategories = ['TV', 'Cellular', 'Internet', 'Disney+', 'Netflix', 'Luxury', 'Entertainment', 'Travel'];
|
||||||
|
?>
|
||||||
|
<section class="onboarding-intro">
|
||||||
|
<p class="eyebrow">A clean starting point</p>
|
||||||
|
<h1>Let’s plan from what you have now.</h1>
|
||||||
|
<p>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.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="onboarding-progress" aria-label="Onboarding progress">
|
||||||
|
<?php foreach (['Cash now', 'Bill count', 'Build bills', 'Preferences'] as $index => $label): ?>
|
||||||
|
<div class="<?= $index === 0 ? 'active' : '' ?>" data-onboarding-progress="<?= $index + 1 ?>">
|
||||||
|
<span><?= $index + 1 ?></span><strong><?= h($label) ?></strong>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="onboarding-form card" data-onboarding-form data-symbol="<?= h($symbol) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="action" value="onboarding.complete">
|
||||||
|
|
||||||
|
<section class="onboarding-step active" data-onboarding-step="1">
|
||||||
|
<div class="step-copy">
|
||||||
|
<span class="step-number">01</span>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Start with the truth</p>
|
||||||
|
<h2>How much money do you have in total right now?</h2>
|
||||||
|
<p>Include the cash you consider usable across checking, cash, and other active accounts. This is your manual starting point, not a forecast.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="hero-money-field">
|
||||||
|
<span>Current available cash</span>
|
||||||
|
<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="current_cash" min="0" step="0.01" inputmode="decimal" required autofocus placeholder="0.00" data-current-cash></div>
|
||||||
|
<small>Anything left at month-end can roll into the next month automatically.</small>
|
||||||
|
</label>
|
||||||
|
<div class="onboarding-actions">
|
||||||
|
<span></span>
|
||||||
|
<button class="button primary" type="button" data-onboarding-next>Continue to bills <?= icon('plus') ?></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="onboarding-step" data-onboarding-step="2" hidden>
|
||||||
|
<div class="step-copy">
|
||||||
|
<span class="step-number">02</span>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Map the obligations</p>
|
||||||
|
<h2>How many bills do you have in total?</h2>
|
||||||
|
<p>We will generate one simple setup card for each bill. Enter zero if you would rather add bills from the dashboard later.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="bill-count-field">
|
||||||
|
<span>Number of bills</span>
|
||||||
|
<input type="number" name="bill_count" min="0" max="50" step="1" inputmode="numeric" required value="0" data-bill-count>
|
||||||
|
<small>Up to 50 can be created during onboarding.</small>
|
||||||
|
</label>
|
||||||
|
<div class="onboarding-actions">
|
||||||
|
<button class="button secondary" type="button" data-onboarding-back>Back</button>
|
||||||
|
<button class="button primary" type="button" data-onboarding-next>Generate bill fields</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="onboarding-step" data-onboarding-step="3" hidden>
|
||||||
|
<div class="step-copy">
|
||||||
|
<span class="step-number">03</span>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Turn bills into targets</p>
|
||||||
|
<h2>Build the first month’s bill plan.</h2>
|
||||||
|
<p>Keep a category specific to the bill, or consolidate related bills such as electric, water, sewage, and gas under Utilities.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="check-row onboarding-check-all">
|
||||||
|
<input type="checkbox" data-mark-all-paid>
|
||||||
|
<span><strong>Mark every entered bill as paid today</strong><small>Each payment will reduce the available balance and create a transaction dated <?= h(date('F j, Y', strtotime($today))) ?> with the note “onboarding.”</small></span>
|
||||||
|
</label>
|
||||||
|
<div class="onboarding-bills" data-bill-rows></div>
|
||||||
|
<div class="onboarding-actions">
|
||||||
|
<button class="button secondary" type="button" data-onboarding-back>Back</button>
|
||||||
|
<button class="button primary" type="button" data-onboarding-next>Continue to preferences</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="onboarding-step" data-onboarding-step="4" hidden>
|
||||||
|
<div class="step-copy">
|
||||||
|
<span class="step-number">04</span>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Set aside and finish</p>
|
||||||
|
<h2>Add useful categories and decide what stays out of sight.</h2>
|
||||||
|
<p>These choices are optional. Everything can be changed later from the administrator dashboard.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-preference-grid">
|
||||||
|
<div class="onboarding-option-panel">
|
||||||
|
<div class="option-heading">
|
||||||
|
<span class="metric-icon blue"><?= icon('expense') ?></span>
|
||||||
|
<div><h3>Additional categories</h3><p>Create common categories now, even if they are not connected to a bill yet.</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="category-chip-grid">
|
||||||
|
<?php foreach ($extraCategories as $category): ?>
|
||||||
|
<label><input type="checkbox" name="extra_categories[]" value="<?= h($category) ?>"><span><?= h($category) ?></span></label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-option-panel dead-option">
|
||||||
|
<div class="option-heading">
|
||||||
|
<span class="metric-icon purple"><?= icon('vault') ?></span>
|
||||||
|
<div><h3>Dormant account</h3><p>Move money out of the usable budget without losing it. The hidden balance can be restored to income or a bill later.</p></div>
|
||||||
|
</div>
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="enable_dead_account" value="1" data-enable-onboarding-dead>
|
||||||
|
<span><strong>Set money aside now</strong><small>Unchecked by default. This reduces the money available for the month.</small></span>
|
||||||
|
</label>
|
||||||
|
<label data-onboarding-dead-fields hidden>
|
||||||
|
Amount to make dormant
|
||||||
|
<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="dead_amount" min="0" step="0.01" inputmode="decimal" placeholder="0.00" data-dead-amount></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-review">
|
||||||
|
<div><span>Starting cash</span><strong data-review-cash><?= h($symbol) ?>0.00</strong></div>
|
||||||
|
<div><span>Paid during setup</span><strong data-review-paid><?= h($symbol) ?>0.00</strong></div>
|
||||||
|
<div><span>Made dormant</span><strong data-review-dead><?= h($symbol) ?>0.00</strong></div>
|
||||||
|
<div class="review-remaining"><span>Ready to budget</span><strong data-review-remaining><?= h($symbol) ?>0.00</strong></div>
|
||||||
|
</div>
|
||||||
|
<p class="onboarding-warning" data-onboarding-warning hidden>Payments and dormant money exceed the cash you entered. Adjust an amount before finishing.</p>
|
||||||
|
|
||||||
|
<div class="onboarding-actions">
|
||||||
|
<button class="button secondary" type="button" data-onboarding-back>Back</button>
|
||||||
|
<button class="button primary" type="submit" data-onboarding-submit>Finish and open dashboard</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template id="onboarding-bill-template">
|
||||||
|
<article class="onboarding-bill-card">
|
||||||
|
<div class="bill-card-heading">
|
||||||
|
<span class="bill-index" data-bill-index>1</span>
|
||||||
|
<div><h3 data-bill-title>Bill 1</h3><p>Creates a progress-tracked budget target.</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="bill-fields">
|
||||||
|
<label>Bill name<input type="text" maxlength="255" required placeholder="Electric" data-bill-field="name"></label>
|
||||||
|
<label>Usual amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" min="0" step="0.01" inputmode="decimal" required placeholder="0.00" data-bill-field="amount"></div></label>
|
||||||
|
<label>Due date<input type="date" min="<?= h($month . '-01') ?>" max="<?= h($lastDay) ?>" value="<?= h($today) ?>" data-bill-field="due_date"></label>
|
||||||
|
<label>Repeats
|
||||||
|
<select data-bill-field="recurrence">
|
||||||
|
<option value="monthly" selected>Every month</option>
|
||||||
|
<option value="yearly">Every year</option>
|
||||||
|
<option value="none">One time</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="bill-category-field">Category
|
||||||
|
<select data-bill-field="category">
|
||||||
|
<option value="__bill__">Use the bill name</option>
|
||||||
|
<?php foreach ($categoryOptions as $category): ?>
|
||||||
|
<option value="<?= h($category) ?>"><?= h($category) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<option value="__custom__">Custom category…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="bill-custom-category" data-custom-category hidden>Custom category<input type="text" maxlength="191" placeholder="My category" data-bill-field="custom_category"></label>
|
||||||
|
</div>
|
||||||
|
<label class="check-row bill-paid-row">
|
||||||
|
<input type="checkbox" value="1" data-bill-field="paid_now">
|
||||||
|
<span><strong>This bill was paid today</strong><small>Deduct the amount now and add an “onboarding” transaction to history.</small></span>
|
||||||
|
</label>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
+15
-5
@@ -73,7 +73,15 @@
|
|||||||
|
|
||||||
<article class="card" id="security">
|
<article class="card" id="security">
|
||||||
<div class="card-heading"><div><p class="eyebrow">Administrator protection</p><h2>Security</h2></div></div>
|
<div class="card-heading"><div><p class="eyebrow">Administrator protection</p><h2>Security</h2></div></div>
|
||||||
<label class="check-row"><input type="checkbox" name="security_2fa_required" value="1" <?= $s['security.2fa_required'] ? 'checked' : '' ?>><span><strong>Require two-factor authentication</strong><small>Uses any standard TOTP authenticator app.</small></span></label>
|
<div class="form-grid">
|
||||||
|
<label>Two-factor method
|
||||||
|
<select name="security_2fa_method">
|
||||||
|
<option value="totp" <?= $s['security.2fa_method'] === 'totp' ? 'selected' : '' ?>>Authenticator app</option>
|
||||||
|
<option value="email" <?= $s['security.2fa_method'] === 'email' ? 'selected' : '' ?>>Email through Mailgun</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="check-row"><input type="checkbox" name="security_2fa_required" value="1" <?= $s['security.2fa_required'] ? 'checked' : '' ?>><span><strong>Require two-factor authentication</strong><small>Email delivery requires complete Mailgun settings above.</small></span></label>
|
||||||
|
</div>
|
||||||
<p class="form-note">Administrator: <?= h($user['email']) ?> · account created <?= h(date('M j, Y', strtotime($user['created_at']))) ?></p>
|
<p class="form-note">Administrator: <?= h($user['email']) ?> · account created <?= h(date('M j, Y', strtotime($user['created_at']))) ?></p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -83,10 +91,12 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="settings-actions">
|
<section class="settings-actions">
|
||||||
<form method="post">
|
<?php if (($s['security.2fa_method'] ?? 'totp') === 'totp'): ?>
|
||||||
<?= csrf_field() ?><input type="hidden" name="action" value="2fa.regenerate">
|
<form method="post">
|
||||||
<button class="button secondary" type="submit">Generate new 2FA key</button>
|
<?= csrf_field() ?><input type="hidden" name="action" value="2fa.regenerate">
|
||||||
</form>
|
<button class="button secondary" type="submit">Generate new 2FA key</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if ($settings->module('alerts')): ?>
|
<?php if ($settings->module('alerts')): ?>
|
||||||
<form method="post" data-confirm="Run the reminder scan now? Configured emails may be sent through Mailgun.">
|
<form method="post" data-confirm="Run the reminder scan now? Configured emails may be sent through Mailgun.">
|
||||||
<?= csrf_field() ?><input type="hidden" name="action" value="alerts.run">
|
<?= csrf_field() ?><input type="hidden" name="action" value="alerts.run">
|
||||||
|
|||||||
+8
-5
@@ -1,14 +1,17 @@
|
|||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<p class="eyebrow">First run</p>
|
<p class="eyebrow">Welcome to Neon Ledger</p>
|
||||||
<h2>Create the administrator</h2>
|
<h2>Create your private planner</h2>
|
||||||
<p class="muted">This account controls the planner, its modules, exports, and alert settings.</p>
|
<p class="muted">Plan from the money you actually have, build monthly bill targets, and carry unused funds safely into the next month.</p>
|
||||||
<form method="post" class="stack-form">
|
<form method="post" class="stack-form">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="action" value="setup">
|
<input type="hidden" name="action" value="setup">
|
||||||
<label>Email address<input type="email" name="email" required autocomplete="email" placeholder="you@example.com"></label>
|
<label>Email address<input type="email" name="email" required autocomplete="email" placeholder="you@example.com"></label>
|
||||||
<label>Password<input type="password" name="password" required minlength="10" autocomplete="new-password" placeholder="At least 10 characters"></label>
|
<label>Password<input type="password" name="password" required minlength="10" autocomplete="new-password" placeholder="At least 10 characters"></label>
|
||||||
<label>Confirm password<input type="password" name="password_confirmation" required minlength="10" autocomplete="new-password"></label>
|
<label>Confirm password<input type="password" name="password_confirmation" required minlength="10" autocomplete="new-password"></label>
|
||||||
<label class="check-row"><input type="checkbox" name="enable_2fa" value="1"><span><strong>Enable optional 2FA now</strong><small>You will receive a setup key after account creation.</small></span></label>
|
<div class="auth-info">
|
||||||
<button class="button primary full" type="submit">Create planner</button>
|
<strong>Protection and reminders come next</strong>
|
||||||
|
<small>Optional authenticator or email-delivered 2FA and Mailgun bill alerts are available in Configuration after onboarding.</small>
|
||||||
|
</div>
|
||||||
|
<button class="button primary full" type="submit">Create account and continue</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<p class="eyebrow">Second step</p>
|
<p class="eyebrow">Second step</p>
|
||||||
<h2>Authentication code</h2>
|
<h2>Authentication code</h2>
|
||||||
<p class="muted">Enter the current six-digit code from your authenticator.</p>
|
<p class="muted"><?= $twoFactorMethod === 'email'
|
||||||
|
? 'Enter the six-digit code sent to the administrator email. It expires in 10 minutes.'
|
||||||
|
: 'Enter the current six-digit code from your authenticator.' ?></p>
|
||||||
<form method="post" class="stack-form">
|
<form method="post" class="stack-form">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="action" value="verify_2fa">
|
<input type="hidden" name="action" value="verify_2fa">
|
||||||
|
|||||||
Reference in New Issue
Block a user