From b7eaa815013d2401367d7b6f17510602f014167e Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Sun, 14 Jun 2026 15:25:31 -0400 Subject: [PATCH] - --- .gitignore | 6 + README.md | 60 +++- app/Auth.php | 114 +++++++ app/BudgetService.php | 630 +++++++++++++++++++++++++++++++++++++ app/Database.php | 164 ++++++++++ app/ExportService.php | 221 +++++++++++++ app/Mailgun.php | 59 ++++ app/ReminderService.php | 99 ++++++ app/Schema.php | 276 ++++++++++++++++ app/Settings.php | 96 ++++++ app/Support.php | 79 +++++ app/Totp.php | 77 +++++ app/bootstrap.php | 38 +++ config/database.json | 12 + public/.htaccess | 8 + public/assets/app.css | 518 ++++++++++++++++++++++++++++++ public/assets/app.js | 181 +++++++++++ public/index.php | 461 +++++++++++++++++++++++++++ router.php | 12 + scripts/send-reminders.php | 15 + storage/database/.gitkeep | 1 + storage/sessions/.gitkeep | 1 + views/auth-layout.php | 33 ++ views/calendar.php | 52 +++ views/comparisons.php | 63 ++++ views/dashboard.php | 144 +++++++++ views/data.php | 46 +++ views/dead-account.php | 62 ++++ views/layout.php | 88 ++++++ views/login.php | 12 + views/settings.php | 107 +++++++ views/setup.php | 14 + views/transactions.php | 56 ++++ views/two-factor.php | 11 + 34 files changed, 3814 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 app/Auth.php create mode 100644 app/BudgetService.php create mode 100644 app/Database.php create mode 100644 app/ExportService.php create mode 100644 app/Mailgun.php create mode 100644 app/ReminderService.php create mode 100644 app/Schema.php create mode 100644 app/Settings.php create mode 100644 app/Support.php create mode 100644 app/Totp.php create mode 100644 app/bootstrap.php create mode 100644 config/database.json create mode 100644 public/.htaccess create mode 100644 public/assets/app.css create mode 100644 public/assets/app.js create mode 100644 public/index.php create mode 100644 router.php create mode 100644 scripts/send-reminders.php create mode 100644 storage/database/.gitkeep create mode 100644 storage/sessions/.gitkeep create mode 100644 views/auth-layout.php create mode 100644 views/calendar.php create mode 100644 views/comparisons.php create mode 100644 views/dashboard.php create mode 100644 views/data.php create mode 100644 views/dead-account.php create mode 100644 views/layout.php create mode 100644 views/login.php create mode 100644 views/settings.php create mode 100644 views/setup.php create mode 100644 views/transactions.php create mode 100644 views/two-factor.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90356aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/storage/database/* +!/storage/database/.gitkeep +/storage/sessions/* +!/storage/sessions/.gitkeep +/.DS_Store +*.log diff --git a/README.md b/README.md index 3879ac1..8b9b410 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,59 @@ -# budget +# Neon Ledger -A financial planner. \ No newline at end of file +A self-contained monthly financial planner built with PHP, SQLite, JavaScript, and HTML5. It tracks rolling budget remainders, income withholding, bill progress, categorized expenses, dormant savings, calendar due dates, comparisons, and Mailgun reminders from an administrator dashboard. + +## Requirements + +- PHP 8.1 or newer +- PDO SQLite extension +- Optional: PDO MySQL for MySQL/MariaDB migration +- HTTPS is recommended outside local development + +No Composer or Node dependencies are required. + +## Start locally + +```bash +php -S 127.0.0.1:8080 router.php +``` + +Open `http://127.0.0.1:8080` and create the first administrator account. The SQLite database is created automatically at `storage/database/budget.sqlite`. + +For Apache, use `public/` as the document root and enable `mod_rewrite`. + +## Core behavior + +- Each month has a manual starting income and an explicit previous-month remainder. +- Positive remaining funds can roll into the next month. Use **Recalculate** to refresh an existing month's rollover after changing the prior month. +- Income and expense rules can move a percentage or fixed amount into the dormant account automatically. +- Dormant deposits reduce usable funds; restorations increase the selected month's usable funds. +- Payments linked to a budget item progress its paid and owing totals. +- Monthly recurring items copy from the prior month; yearly items copy from the same month in the prior year. +- JSON exports use the `neon-ledger/budget-planner` version 1 format and exclude credentials, 2FA secrets, and the Mailgun API key. + +## Bill reminders + +Configure Mailgun, recipient, alert windows, and reminder times in **Settings**. Run the reminder worker every minute: + +```cron +* * * * * cd /path/to/neon-ledger && php scripts/send-reminders.php +``` + +Use `php scripts/send-reminders.php --force` to scan immediately. Duplicate reminders for the same bill, recipient, window, and day are suppressed. + +## Optional MySQL/MariaDB mode + +SQLite is the default and is always sufficient. The **Import & export** screen can create the MySQL schema, copy all SQLite records, and update `config/database.json` in one step. The original SQLite file remains untouched as a fallback snapshot. + +Create the target database and user before running the transfer. The PHP runtime must include `pdo_mysql`. + +## Backup + +Use **Export JSON** for a portable planner package. For a direct SQLite backup, copy `storage/database/budget.sqlite` while the app is stopped. + +## Security + +- Passwords use PHP's current `PASSWORD_DEFAULT` algorithm. +- All state-changing forms use CSRF tokens. +- Optional TOTP two-factor authentication works with standard authenticator apps. +- The application should be served with `public/` as its web root. diff --git a/app/Auth.php b/app/Auth.php new file mode 100644 index 0000000..8fee7df --- /dev/null +++ b/app/Auth.php @@ -0,0 +1,114 @@ +pdo->query('SELECT COUNT(*) FROM users')->fetchColumn() === 0; + } + + public function setup(string $email, string $password, bool $enableTwoFactor): ?string + { + if (!$this->needsSetup()) { + throw new \RuntimeException('The administrator account already exists.'); + } + $secret = $enableTwoFactor ? Totp::generateSecret() : null; + $statement = $this->pdo->prepare( + 'INSERT INTO users (email, password_hash, totp_secret) VALUES (:email, :password_hash, :secret)' + ); + $statement->execute([ + 'email' => strtolower(trim($email)), + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + 'secret' => $secret, + ]); + $this->settings->set('alerts.email', strtolower(trim($email))); + $this->settings->set('security.2fa_required', $enableTwoFactor); + $_SESSION['user_id'] = (int) $this->pdo->lastInsertId(); + session_regenerate_id(true); + return $secret; + } + + public function attempt(string $email, string $password): string + { + $statement = $this->pdo->prepare('SELECT * FROM users WHERE email = :email LIMIT 1'); + $statement->execute(['email' => strtolower(trim($email))]); + $user = $statement->fetch(); + if (!$user || !password_verify($password, (string) $user['password_hash'])) { + return 'invalid'; + } + + if ((bool) $this->settings->get('security.2fa_required', false)) { + if (empty($user['totp_secret'])) { + return 'unconfigured_2fa'; + } + $_SESSION['pending_2fa_user'] = (int) $user['id']; + return '2fa'; + } + + $this->completeLogin((int) $user['id']); + return 'ok'; + } + + public function verifyTwoFactor(string $code): bool + { + $userId = (int) ($_SESSION['pending_2fa_user'] ?? 0); + if ($userId === 0) { + return false; + } + $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']); + $this->completeLogin($userId); + return true; + } + + public function check(): bool + { + return isset($_SESSION['user_id']); + } + + public function id(): ?int + { + return $this->check() ? (int) $_SESSION['user_id'] : null; + } + + public function user(): ?array + { + if (!$this->check()) { + return null; + } + $statement = $this->pdo->prepare('SELECT id, email, totp_secret, created_at FROM users WHERE id = :id'); + $statement->execute(['id' => $this->id()]); + return $statement->fetch() ?: null; + } + + public function logout(): void + { + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $parameters = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $parameters['path'], $parameters['domain'], $parameters['secure'], $parameters['httponly']); + } + session_destroy(); + } + + private function completeLogin(int $userId): void + { + $_SESSION['user_id'] = $userId; + session_regenerate_id(true); + } +} diff --git a/app/BudgetService.php b/app/BudgetService.php new file mode 100644 index 0000000..a957744 --- /dev/null +++ b/app/BudgetService.php @@ -0,0 +1,630 @@ +plan($month); + if ($plan) { + $this->materializeRecurringItems($month); + return $plan; + } + + $previousMonth = (new DateTimeImmutable($month . '-01'))->modify('-1 month')->format('Y-m'); + $previous = $this->plan($previousMonth); + $rollover = 0.0; + if ($previous && (bool) $previous['rolling_enabled']) { + $rollover = max(0, (float) $this->summary($previousMonth, false)['remaining']); + } + + $statement = $this->pdo->prepare( + 'INSERT INTO monthly_plans (month, rollover_in, rolling_enabled, rollover_source_month) + VALUES (:month, :rollover, 1, :source)' + ); + $statement->execute([ + 'month' => $month, + 'rollover' => $rollover, + 'source' => $previous ? $previousMonth : null, + ]); + $this->materializeRecurringItems($month); + return $this->plan($month) ?: throw new RuntimeException('Unable to create monthly plan.'); + } + + public function plan(string $month): ?array + { + $statement = $this->pdo->prepare('SELECT * FROM monthly_plans WHERE month = :month'); + $statement->execute(['month' => Support::month($month)]); + return $statement->fetch() ?: null; + } + + public function savePlan(string $month, float $startingIncome, float $rollover, bool $rollingEnabled, string $notes): void + { + $plan = $this->ensureMonth($month); + $withholding = $this->automaticAmount( + $startingIncome, + (string) $this->settings->get('dead.income_mode', 'percent'), + (float) $this->settings->get('dead.income_value', 0) + ); + + $this->pdo->beginTransaction(); + try { + $statement = $this->pdo->prepare( + 'UPDATE monthly_plans + SET starting_income = :starting_income, rollover_in = :rollover_in, + rolling_enabled = :rolling_enabled, notes = :notes, updated_at = CURRENT_TIMESTAMP + WHERE id = :id' + ); + $statement->execute([ + 'starting_income' => max(0, $startingIncome), + 'rollover_in' => max(0, $rollover), + 'rolling_enabled' => $rollingEnabled ? 1 : 0, + 'notes' => trim($notes) ?: null, + 'id' => $plan['id'], + ]); + $this->replaceDeadSource( + $month, + 'income_withholding', + $withholding, + 'monthly_plan', + (int) $plan['id'], + 'Automatic withholding from the monthly starting point' + ); + $this->pdo->commit(); + } catch (\Throwable $exception) { + $this->pdo->rollBack(); + throw $exception; + } + } + + public function refreshRollover(string $month): float + { + $plan = $this->ensureMonth($month); + if (!(bool) $plan['rolling_enabled']) { + return (float) $plan['rollover_in']; + } + $previousMonth = (new DateTimeImmutable($month . '-01'))->modify('-1 month')->format('Y-m'); + $previous = $this->plan($previousMonth); + $rollover = $previous ? max(0, (float) $this->summary($previousMonth, false)['remaining']) : 0.0; + $statement = $this->pdo->prepare( + 'UPDATE monthly_plans SET rollover_in = :rollover, rollover_source_month = :source, updated_at = CURRENT_TIMESTAMP WHERE id = :id' + ); + $statement->execute(['rollover' => $rollover, 'source' => $previous ? $previousMonth : null, 'id' => $plan['id']]); + return $rollover; + } + + public function addIncome(string $month, string $label, float $amount, string $receivedOn, string $notes): int + { + $this->ensureMonth($month); + $amount = max(0, $amount); + if ($amount <= 0 || trim($label) === '') { + throw new RuntimeException('Income needs a label and an amount greater than zero.'); + } + $withholding = $this->automaticAmount( + $amount, + (string) $this->settings->get('dead.income_mode', 'percent'), + (float) $this->settings->get('dead.income_value', 0) + ); + $this->pdo->beginTransaction(); + try { + $statement = $this->pdo->prepare( + 'INSERT INTO income_entries (month, label, amount, received_on, withholding_amount, notes) + VALUES (:month, :label, :amount, :received_on, :withholding, :notes)' + ); + $statement->execute([ + 'month' => $month, + 'label' => trim($label), + 'amount' => $amount, + 'received_on' => $receivedOn, + 'withholding' => $withholding, + 'notes' => trim($notes) ?: null, + ]); + $id = (int) $this->pdo->lastInsertId(); + if ($withholding > 0) { + $this->insertDeadEntry( + $month, + 'income_withholding', + $withholding, + 'income', + $id, + 'Automatic withholding from ' . trim($label) + ); + } + $this->pdo->commit(); + return $id; + } catch (\Throwable $exception) { + $this->pdo->rollBack(); + throw $exception; + } + } + + public function deleteIncome(int $id): void + { + $this->pdo->beginTransaction(); + try { + $statement = $this->pdo->prepare('DELETE FROM dead_account_entries WHERE source_type = :type AND source_id = :id'); + $statement->execute(['type' => 'income', 'id' => $id]); + $statement = $this->pdo->prepare('DELETE FROM income_entries WHERE id = :id'); + $statement->execute(['id' => $id]); + $this->pdo->commit(); + } catch (\Throwable $exception) { + $this->pdo->rollBack(); + throw $exception; + } + } + + public function incomes(string $month): array + { + $statement = $this->pdo->prepare('SELECT * FROM income_entries WHERE month = :month ORDER BY received_on DESC, id DESC'); + $statement->execute(['month' => $month]); + return $statement->fetchAll(); + } + + public function addCategory(string $name, string $color, float $monthlyLimit): int + { + if (trim($name) === '') { + throw new RuntimeException('Category name is required.'); + } + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) { + $color = '#7c5cff'; + } + $statement = $this->pdo->prepare( + 'INSERT INTO categories (name, color, monthly_limit) VALUES (:name, :color, :monthly_limit)' + ); + $statement->execute([ + 'name' => trim($name), + 'color' => $color, + 'monthly_limit' => max(0, $monthlyLimit), + ]); + return (int) $this->pdo->lastInsertId(); + } + + public function categories(): array + { + return $this->pdo->query('SELECT * FROM categories WHERE active = 1 ORDER BY name')->fetchAll(); + } + + public function addBudgetItem( + string $month, + ?int $categoryId, + string $name, + float $plannedAmount, + ?string $dueDate, + string $recurrence, + string $notes + ): int { + $this->ensureMonth($month); + if (trim($name) === '' || $plannedAmount < 0) { + throw new RuntimeException('A budget item needs a name and a non-negative target.'); + } + if (!in_array($recurrence, ['none', 'monthly', 'yearly'], true)) { + $recurrence = 'none'; + } + $series = $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 ?: null, + 'name' => trim($name), + 'planned_amount' => max(0, $plannedAmount), + 'due_date' => $dueDate ?: null, + 'recurrence' => $recurrence, + 'series_key' => $series, + 'notes' => trim($notes) ?: null, + ]); + return (int) $this->pdo->lastInsertId(); + } + + public function deleteBudgetItem(int $id): void + { + $statement = $this->pdo->prepare('DELETE FROM budget_items WHERE id = :id'); + $statement->execute(['id' => $id]); + } + + public function budgetItems(string $month): array + { + $statement = $this->pdo->prepare( + 'SELECT bi.*, c.name AS category_name, c.color AS category_color, + COALESCE(SUM(t.amount), 0) AS paid_amount + FROM budget_items bi + LEFT JOIN categories c ON c.id = bi.category_id + LEFT JOIN transactions t ON t.budget_item_id = bi.id + WHERE bi.month = :month + GROUP BY bi.id, c.name, c.color + ORDER BY CASE WHEN bi.due_date IS NULL THEN 1 ELSE 0 END, bi.due_date, bi.name' + ); + $statement->execute(['month' => $month]); + $items = $statement->fetchAll(); + foreach ($items as &$item) { + $planned = (float) $item['planned_amount']; + $paid = (float) $item['paid_amount']; + $item['remaining_amount'] = max(0, $planned - $paid); + $item['progress'] = $planned > 0 ? min(100, ($paid / $planned) * 100) : ($paid > 0 ? 100 : 0); + } + unset($item); + return $items; + } + + public function addTransaction( + string $month, + ?int $budgetItemId, + ?int $categoryId, + string $merchant, + float $amount, + string $date, + string $notes + ): int { + $this->ensureMonth($month); + if (trim($merchant) === '' || $amount <= 0) { + throw new RuntimeException('An expense needs a description and an amount greater than zero.'); + } + if ($budgetItemId) { + $statement = $this->pdo->prepare('SELECT category_id FROM budget_items WHERE id = :id AND month = :month'); + $statement->execute(['id' => $budgetItemId, 'month' => $month]); + $itemCategory = $statement->fetchColumn(); + if ($itemCategory !== false && !$categoryId) { + $categoryId = $itemCategory ? (int) $itemCategory : null; + } + } + $deadPull = $this->automaticAmount( + $amount, + (string) $this->settings->get('dead.transaction_mode', 'fixed'), + (float) $this->settings->get('dead.transaction_value', 0) + ); + + $this->pdo->beginTransaction(); + try { + $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' => $budgetItemId ?: null, + 'category_id' => $categoryId ?: null, + 'merchant' => trim($merchant), + 'amount' => $amount, + 'transacted_on' => $date, + 'dead_pull' => $deadPull, + 'notes' => trim($notes) ?: null, + ]); + $id = (int) $this->pdo->lastInsertId(); + if ($deadPull > 0) { + $this->insertDeadEntry( + $month, + 'transaction_pull', + $deadPull, + 'transaction', + $id, + 'Automatic dormant transfer for ' . trim($merchant) + ); + } + $this->pdo->commit(); + return $id; + } catch (\Throwable $exception) { + $this->pdo->rollBack(); + throw $exception; + } + } + + public function deleteTransaction(int $id): void + { + $this->pdo->beginTransaction(); + try { + $statement = $this->pdo->prepare('DELETE FROM dead_account_entries WHERE source_type = :type AND source_id = :id'); + $statement->execute(['type' => 'transaction', 'id' => $id]); + $statement = $this->pdo->prepare('DELETE FROM transactions WHERE id = :id'); + $statement->execute(['id' => $id]); + $this->pdo->commit(); + } catch (\Throwable $exception) { + $this->pdo->rollBack(); + throw $exception; + } + } + + public function transactions(string $month): array + { + $statement = $this->pdo->prepare( + 'SELECT t.*, bi.name AS item_name, c.name AS category_name, c.color AS category_color + FROM transactions t + LEFT JOIN budget_items bi ON bi.id = t.budget_item_id + LEFT JOIN categories c ON c.id = t.category_id + WHERE t.month = :month + ORDER BY t.transacted_on DESC, t.id DESC' + ); + $statement->execute(['month' => $month]); + return $statement->fetchAll(); + } + + public function moveDeadAccount(string $month, string $direction, float $amount, string $notes): int + { + $this->ensureMonth($month); + if ($amount <= 0) { + throw new RuntimeException('Transfer amount must be greater than zero.'); + } + if ($direction === 'restore') { + if ($amount > $this->deadBalance() + 0.0001) { + throw new RuntimeException('That restoration is larger than the dormant balance.'); + } + $amount *= -1; + $kind = 'restore'; + } else { + $kind = 'manual_deposit'; + } + return $this->insertDeadEntry($month, $kind, $amount, 'manual', null, trim($notes) ?: ucfirst(str_replace('_', ' ', $kind))); + } + + public function deadEntries(?string $month = null, int $limit = 100): array + { + if ($month) { + $statement = $this->pdo->prepare( + 'SELECT * FROM dead_account_entries WHERE month = :month ORDER BY created_at DESC, id DESC LIMIT ' . (int) $limit + ); + $statement->execute(['month' => $month]); + return $statement->fetchAll(); + } + return $this->pdo->query( + 'SELECT * FROM dead_account_entries ORDER BY created_at DESC, id DESC LIMIT ' . (int) $limit + )->fetchAll(); + } + + public function deadBalance(): float + { + return (float) $this->pdo->query('SELECT COALESCE(SUM(amount), 0) FROM dead_account_entries')->fetchColumn(); + } + + public function addCalendarEvent(string $month, string $title, string $date, string $type, string $notes): int + { + if (trim($title) === '') { + throw new RuntimeException('Calendar event title is required.'); + } + if (!in_array($type, ['note', 'payday', 'deadline'], true)) { + $type = 'note'; + } + $statement = $this->pdo->prepare( + 'INSERT INTO calendar_events (month, title, event_date, event_type, notes) + VALUES (:month, :title, :event_date, :event_type, :notes)' + ); + $statement->execute([ + 'month' => $month, + 'title' => trim($title), + 'event_date' => $date, + 'event_type' => $type, + 'notes' => trim($notes) ?: null, + ]); + return (int) $this->pdo->lastInsertId(); + } + + public function deleteCalendarEvent(int $id): void + { + $statement = $this->pdo->prepare('DELETE FROM calendar_events WHERE id = :id'); + $statement->execute(['id' => $id]); + } + + public function calendarEntries(string $month): array + { + $statement = $this->pdo->prepare( + 'SELECT id, name AS title, due_date AS event_date, "bill" AS event_type, planned_amount AS amount + FROM budget_items WHERE month = :month1 AND due_date IS NOT NULL + UNION ALL + SELECT id, title, event_date, event_type, NULL AS amount + FROM calendar_events WHERE month = :month2 + ORDER BY event_date' + ); + $statement->execute(['month1' => $month, 'month2' => $month]); + return $statement->fetchAll(); + } + + public function summary(string $month, bool $ensure = true): array + { + $month = Support::month($month); + $plan = $ensure ? $this->ensureMonth($month) : $this->plan($month); + if (!$plan) { + return [ + 'month' => $month, 'starting_income' => 0.0, 'rollover_in' => 0.0, + 'additional_income' => 0.0, 'gross_resources' => 0.0, 'expenses' => 0.0, + 'dead_movement' => 0.0, 'remaining' => 0.0, 'planned' => 0.0, + 'paid_to_items' => 0.0, 'unallocated' => 0.0, 'savings_rate' => 0.0, + ]; + } + $income = $this->sum('income_entries', 'amount', $month); + $expenses = $this->sum('transactions', 'amount', $month); + $deadMovement = $this->sum('dead_account_entries', 'amount', $month); + $planned = $this->sum('budget_items', 'planned_amount', $month); + $gross = (float) $plan['starting_income'] + (float) $plan['rollover_in'] + $income; + $remaining = $gross - $expenses - $deadMovement; + return [ + 'month' => $month, + 'starting_income' => (float) $plan['starting_income'], + 'rollover_in' => (float) $plan['rollover_in'], + 'additional_income' => $income, + 'gross_resources' => $gross, + 'expenses' => $expenses, + 'dead_movement' => $deadMovement, + 'remaining' => $remaining, + 'planned' => $planned, + 'paid_to_items' => $this->sumWhere('transactions', 'amount', $month, 'budget_item_id IS NOT NULL'), + 'unallocated' => $gross - $planned - max(0, $deadMovement), + 'savings_rate' => $gross > 0 ? max(0, $deadMovement) / $gross * 100 : 0.0, + ]; + } + + public function comparison(string $endingMonth, int $months = 12): array + { + $ending = new DateTimeImmutable(Support::month($endingMonth) . '-01'); + $rows = []; + for ($offset = $months - 1; $offset >= 0; $offset--) { + $month = $ending->modify('-' . $offset . ' months')->format('Y-m'); + $rows[] = $this->summary($month, false); + } + return $rows; + } + + public function yearlySummary(int $year): array + { + $totals = [ + 'gross_resources' => 0.0, + 'expenses' => 0.0, + 'dead_movement' => 0.0, + 'remaining' => 0.0, + 'planned' => 0.0, + ]; + $months = []; + for ($monthNumber = 1; $monthNumber <= 12; $monthNumber++) { + $month = sprintf('%04d-%02d', $year, $monthNumber); + $summary = $this->summary($month, false); + $months[] = $summary; + foreach ($totals as $key => $unused) { + $totals[$key] += (float) $summary[$key]; + } + } + return ['year' => $year, 'totals' => $totals, 'months' => $months]; + } + + public function categoryStats(string $month): array + { + $statement = $this->pdo->prepare( + 'SELECT COALESCE(c.name, "Uncategorized") AS category_name, + COALESCE(c.color, "#718096") AS color, + COALESCE(c.monthly_limit, 0) AS monthly_limit, + COALESCE(SUM(t.amount), 0) AS spent + FROM transactions t + LEFT JOIN categories c ON c.id = t.category_id + WHERE t.month = :month + GROUP BY c.id, c.name, c.color, c.monthly_limit + ORDER BY spent DESC' + ); + $statement->execute(['month' => $month]); + return $statement->fetchAll(); + } + + public function visitorStats(): array + { + $today = (int) $this->pdo->query( + "SELECT COUNT(DISTINCT visitor_hash) FROM visits WHERE visited_at >= '" . date('Y-m-d') . " 00:00:00'" + )->fetchColumn(); + $thirtyDays = (int) $this->pdo->query( + "SELECT COUNT(DISTINCT visitor_hash) FROM visits WHERE visited_at >= '" . date('Y-m-d H:i:s', strtotime('-30 days')) . "'" + )->fetchColumn(); + $views = (int) $this->pdo->query( + "SELECT COUNT(*) FROM visits WHERE visited_at >= '" . date('Y-m-d H:i:s', strtotime('-30 days')) . "'" + )->fetchColumn(); + return ['today' => $today, 'thirty_days' => $thirtyDays, 'views' => $views]; + } + + private function automaticAmount(float $sourceAmount, string $mode, float $value): float + { + $amount = $mode === 'percent' ? $sourceAmount * max(0, $value) / 100 : max(0, $value); + return round(min(max(0, $sourceAmount), $amount), 2); + } + + private function insertDeadEntry( + string $month, + string $kind, + float $amount, + ?string $sourceType, + ?int $sourceId, + ?string $notes + ): int { + $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, + ]); + return (int) $this->pdo->lastInsertId(); + } + + private function replaceDeadSource( + string $month, + string $kind, + float $amount, + string $sourceType, + int $sourceId, + string $notes + ): void { + $statement = $this->pdo->prepare('DELETE FROM dead_account_entries WHERE source_type = :type AND source_id = :id'); + $statement->execute(['type' => $sourceType, 'id' => $sourceId]); + if ($amount > 0) { + $this->insertDeadEntry($month, $kind, $amount, $sourceType, $sourceId, $notes); + } + } + + private function sum(string $table, string $column, string $month): float + { + $statement = $this->pdo->prepare("SELECT COALESCE(SUM({$column}), 0) FROM {$table} WHERE month = :month"); + $statement->execute(['month' => $month]); + return (float) $statement->fetchColumn(); + } + + private function sumWhere(string $table, string $column, string $month, string $where): float + { + $statement = $this->pdo->prepare("SELECT COALESCE(SUM({$column}), 0) FROM {$table} WHERE month = :month AND {$where}"); + $statement->execute(['month' => $month]); + return (float) $statement->fetchColumn(); + } + + private function materializeRecurringItems(string $month): void + { + $targetDate = new DateTimeImmutable($month . '-01'); + $sources = [ + ['month' => $targetDate->modify('-1 month')->format('Y-m'), 'recurrence' => 'monthly'], + ['month' => $targetDate->modify('-1 year')->format('Y-m'), 'recurrence' => 'yearly'], + ]; + foreach ($sources as $source) { + $statement = $this->pdo->prepare( + 'SELECT * FROM budget_items WHERE month = :month AND recurrence = :recurrence AND series_key IS NOT NULL' + ); + $statement->execute($source); + foreach ($statement->fetchAll() as $item) { + $exists = $this->pdo->prepare('SELECT COUNT(*) FROM budget_items WHERE month = :month AND series_key = :series_key'); + $exists->execute(['month' => $month, 'series_key' => $item['series_key']]); + if ((int) $exists->fetchColumn() > 0) { + continue; + } + $dueDate = null; + if ($item['due_date']) { + $day = (int) substr((string) $item['due_date'], 8, 2); + $lastDay = (int) $targetDate->format('t'); + $dueDate = $month . '-' . str_pad((string) min($day, $lastDay), 2, '0', STR_PAD_LEFT); + } + $insert = $this->pdo->prepare( + 'INSERT INTO budget_items (month, category_id, name, planned_amount, due_date, recurrence, series_key, status, notes) + VALUES (:month, :category_id, :name, :planned_amount, :due_date, :recurrence, :series_key, :status, :notes)' + ); + $insert->execute([ + 'month' => $month, + 'category_id' => $item['category_id'], + 'name' => $item['name'], + 'planned_amount' => $item['planned_amount'], + 'due_date' => $dueDate, + 'recurrence' => $item['recurrence'], + 'series_key' => $item['series_key'], + 'status' => 'active', + 'notes' => $item['notes'], + ]); + } + } + } +} diff --git a/app/Database.php b/app/Database.php new file mode 100644 index 0000000..9ad4289 --- /dev/null +++ b/app/Database.php @@ -0,0 +1,164 @@ +configPath = $configPath; + $this->config = $this->readConfig($configPath); + $driver = (string) ($this->config['driver'] ?? 'sqlite'); + + if ($driver === 'mysql') { + $mysql = $this->config['mysql'] ?? []; + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $mysql['host'] ?? '127.0.0.1', + (int) ($mysql['port'] ?? 3306), + $mysql['database'] ?? 'budget_planner', + $mysql['charset'] ?? 'utf8mb4' + ); + $this->pdo = new PDO($dsn, (string) ($mysql['username'] ?? ''), (string) ($mysql['password'] ?? ''), self::options()); + } else { + $path = (string) ($this->config['sqlite_path'] ?? 'storage/database/budget.sqlite'); + $absolutePath = str_starts_with($path, '/') ? $path : $rootPath . '/' . $path; + $directory = dirname($absolutePath); + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException('Unable to create SQLite storage directory.'); + } + $this->pdo = new PDO('sqlite:' . $absolutePath, null, null, self::options()); + $this->pdo->exec('PRAGMA foreign_keys = ON'); + $this->pdo->exec('PRAGMA journal_mode = WAL'); + } + + $this->migrate(); + } + + public function pdo(): PDO + { + return $this->pdo; + } + + public function driver(): string + { + return (string) ($this->config['driver'] ?? 'sqlite'); + } + + public function config(): array + { + return $this->config; + } + + public function migrate(): void + { + foreach (Schema::statements($this->driver()) as $statement) { + $this->pdo->exec($statement); + } + } + + public function migrateSqliteToMysql(array $mysqlConfig, string $rootPath): int + { + if ($this->driver() !== 'sqlite') { + throw new RuntimeException('Automatic migration can only start while SQLite mode is active.'); + } + + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $mysqlConfig['host'], + (int) $mysqlConfig['port'], + $mysqlConfig['database'], + $mysqlConfig['charset'] ?? 'utf8mb4' + ); + $target = new PDO($dsn, (string) $mysqlConfig['username'], (string) $mysqlConfig['password'], self::options()); + foreach (Schema::statements('mysql') as $statement) { + $target->exec($statement); + } + + $tables = [ + 'settings', 'users', 'monthly_plans', 'income_entries', 'categories', + 'budget_items', 'transactions', 'dead_account_entries', 'calendar_events', + 'alert_logs', 'visits', 'audit_logs', + ]; + $copied = 0; + $target->beginTransaction(); + try { + $target->exec('SET FOREIGN_KEY_CHECKS=0'); + foreach ($tables as $table) { + $rows = $this->pdo->query('SELECT * FROM ' . $table)->fetchAll(); + if ($rows === []) { + continue; + } + $columns = array_keys($rows[0]); + $quoted = array_map(static fn (string $column): string => '`' . $column . '`', $columns); + $placeholders = array_map(static fn (string $column): string => ':' . $column, $columns); + $sql = sprintf( + 'REPLACE INTO `%s` (%s) VALUES (%s)', + $table, + implode(', ', $quoted), + implode(', ', $placeholders) + ); + $insert = $target->prepare($sql); + foreach ($rows as $row) { + $insert->execute($row); + $copied++; + } + } + $target->exec('SET FOREIGN_KEY_CHECKS=1'); + $target->commit(); + } catch (\Throwable $exception) { + if ($target->inTransaction()) { + $target->rollBack(); + } + throw $exception; + } + + $newConfig = [ + 'driver' => 'mysql', + 'sqlite_path' => $this->config['sqlite_path'] ?? 'storage/database/budget.sqlite', + 'mysql' => [ + 'host' => $mysqlConfig['host'], + 'port' => (int) $mysqlConfig['port'], + 'database' => $mysqlConfig['database'], + 'username' => $mysqlConfig['username'], + 'password' => $mysqlConfig['password'], + 'charset' => $mysqlConfig['charset'] ?? 'utf8mb4', + ], + ]; + $encoded = json_encode($newConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($encoded === false || file_put_contents($this->configPath, $encoded . PHP_EOL, LOCK_EX) === false) { + throw new RuntimeException('Data copied, but config/database.json could not be updated.'); + } + + return $copied; + } + + private static function options(): array + { + return [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + } + + private function readConfig(string $path): array + { + $raw = @file_get_contents($path); + $config = $raw === false ? null : json_decode($raw, true); + if (!is_array($config)) { + throw new RuntimeException('Database configuration is missing or invalid.'); + } + return $config; + } +} diff --git a/app/ExportService.php b/app/ExportService.php new file mode 100644 index 0000000..997228e --- /dev/null +++ b/app/ExportService.php @@ -0,0 +1,221 @@ +pdo->query('SELECT * FROM ' . $table)->fetchAll(); + } + $safeSettings = []; + foreach ($this->settings->all() as $key => $value) { + if (!str_starts_with($key, 'mailgun.api_key') && !str_starts_with($key, 'security.')) { + $safeSettings[$key] = $value; + } + } + $package = [ + 'format' => 'neon-ledger/budget-planner', + 'version' => 1, + 'exported_at' => date(DATE_ATOM), + 'settings' => $safeSettings, + 'data' => $data, + ]; + $json = json_encode($package, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + throw new RuntimeException('Planner package could not be encoded.'); + } + return $json; + } + + public function importPackage(string $json, bool $replace): array + { + $package = json_decode($json, true); + if (!is_array($package) + || ($package['format'] ?? '') !== 'neon-ledger/budget-planner' + || (int) ($package['version'] ?? 0) !== 1 + || !is_array($package['data'] ?? null) + ) { + throw new RuntimeException('This is not a compatible Neon Ledger planner package.'); + } + + $inserted = []; + $this->pdo->beginTransaction(); + try { + if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql') { + $this->pdo->exec('SET FOREIGN_KEY_CHECKS=0'); + } else { + $this->pdo->exec('PRAGMA defer_foreign_keys = ON'); + } + if ($replace) { + foreach (array_reverse(self::TABLES) as $table) { + $this->pdo->exec('DELETE FROM ' . $table); + } + } + foreach (self::TABLES as $table) { + $rows = $package['data'][$table] ?? []; + $inserted[$table] = 0; + if (!is_array($rows)) { + continue; + } + foreach ($rows as $row) { + if (!is_array($row) || $row === []) { + continue; + } + $columns = array_keys($row); + $columnSql = implode(', ', array_map( + fn (string $column): string => $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql' + ? '`' . $column . '`' + : '"' . $column . '"', + $columns + )); + $placeholders = implode(', ', array_map(static fn (string $column): string => ':' . $column, $columns)); + $verb = $replace + ? ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql' ? 'REPLACE' : 'INSERT OR REPLACE') + : ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql' ? 'INSERT IGNORE' : 'INSERT OR IGNORE'); + $statement = $this->pdo->prepare(sprintf('%s INTO %s (%s) VALUES (%s)', $verb, $table, $columnSql, $placeholders)); + $statement->execute($row); + $inserted[$table] += $statement->rowCount(); + } + } + foreach ((array) ($package['settings'] ?? []) as $key => $value) { + if (is_string($key) && !str_starts_with($key, 'mailgun.api_key') && !str_starts_with($key, 'security.')) { + $this->settings->set($key, $value); + } + } + if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql') { + $this->pdo->exec('SET FOREIGN_KEY_CHECKS=1'); + } + $this->pdo->commit(); + } catch (\Throwable $exception) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $exception; + } + return $inserted; + } + + public function html(string $month): string + { + $summary = $this->budget->summary($month); + $items = $this->budget->budgetItems($month); + $transactions = $this->budget->transactions($month); + $symbol = (string) $this->settings->get('app.currency_symbol', '$'); + $escape = static fn (mixed $value): string => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + $money = static fn (mixed $value): string => Support::money((float) $value, $symbol); + + $itemRows = ''; + foreach ($items as $item) { + $itemRows .= sprintf( + '%s%s%s%s%s', + $escape($item['name']), + $escape($item['category_name'] ?: 'Uncategorized'), + $money($item['planned_amount']), + $money($item['paid_amount']), + $money($item['remaining_amount']) + ); + } + $transactionRows = ''; + foreach ($transactions as $transaction) { + $transactionRows .= sprintf( + '%s%s%s%s', + $escape($transaction['transacted_on']), + $escape($transaction['merchant']), + $escape($transaction['category_name'] ?: 'Uncategorized'), + $money($transaction['amount']) + ); + } + + return 'Budget report ' . $escape($month) . '' + . '' + . '' + . '

' . $escape($this->settings->get('app.name', 'Neon Ledger')) . ' — ' . $escape(date('F Y', strtotime($month . '-01'))) . '

' + . '
Resources' . $money($summary['gross_resources']) . '
' + . '
Expenses' . $money($summary['expenses']) . '
' + . '
Dormant movement' . $money($summary['dead_movement']) . '
' + . '
Remaining' . $money($summary['remaining']) . '
' + . '

Budget progress

' + . $itemRows . '
ItemCategoryTargetPaidOwing

Expenses

' + . $transactionRows . '
DateDescriptionCategoryAmount

Exported ' . $escape(date(DATE_RFC2822)) . '

'; + } + + public function pdf(string $month): string + { + $summary = $this->budget->summary($month); + $items = $this->budget->budgetItems($month); + $symbol = (string) $this->settings->get('app.currency_symbol', '$'); + $lines = [ + (string) $this->settings->get('app.name', 'Neon Ledger') . ' - ' . date('F Y', strtotime($month . '-01')), + '', + 'Resources: ' . Support::money($summary['gross_resources'], $symbol), + 'Expenses: ' . Support::money($summary['expenses'], $symbol), + 'Dormant movement: ' . Support::money($summary['dead_movement'], $symbol), + 'Remaining: ' . Support::money($summary['remaining'], $symbol), + '', + 'BUDGET PROGRESS', + ]; + foreach ($items as $item) { + $lines[] = sprintf( + '%s | target %s | paid %s | owing %s', + $item['name'], + Support::money($item['planned_amount'], $symbol), + Support::money($item['paid_amount'], $symbol), + Support::money($item['remaining_amount'], $symbol) + ); + } + $lines[] = ''; + $lines[] = 'Generated ' . date(DATE_RFC2822); + return $this->simplePdf($lines); + } + + private function simplePdf(array $lines): string + { + $content = "BT\n/F1 11 Tf\n50 760 Td\n14 TL\n"; + foreach ($lines as $index => $line) { + $line = iconv('UTF-8', 'Windows-1252//TRANSLIT', (string) $line) ?: (string) $line; + $line = str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $line); + $content .= ($index === 0 ? '' : "T*\n") . '(' . $line . ") Tj\n"; + } + $content .= "ET\n"; + $objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + '<< /Length ' . strlen($content) . " >>\nstream\n" . $content . 'endstream', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ]; + $pdf = "%PDF-1.4\n"; + $offsets = [0]; + foreach ($objects as $number => $object) { + $offsets[] = strlen($pdf); + $pdf .= ($number + 1) . " 0 obj\n" . $object . "\nendobj\n"; + } + $xref = strlen($pdf); + $pdf .= "xref\n0 " . (count($objects) + 1) . "\n0000000000 65535 f \n"; + for ($index = 1; $index <= count($objects); $index++) { + $pdf .= sprintf("%010d 00000 n \n", $offsets[$index]); + } + $pdf .= 'trailer << /Size ' . (count($objects) + 1) . " /Root 1 0 R >>\nstartxref\n" . $xref . "\n%%EOF"; + return $pdf; + } +} diff --git a/app/Mailgun.php b/app/Mailgun.php new file mode 100644 index 0000000..8f370a2 --- /dev/null +++ b/app/Mailgun.php @@ -0,0 +1,59 @@ +settings->get('mailgun.api_key', '') !== '' + && $this->settings->get('mailgun.domain', '') !== '' + && $this->settings->get('alerts.email', '') !== ''; + } + + public function send(string $recipient, string $subject, string $html): array + { + if (!$this->configured()) { + throw new RuntimeException('Mailgun settings are incomplete.'); + } + $region = (string) $this->settings->get('mailgun.region', 'us'); + $host = $region === 'eu' ? 'api.eu.mailgun.net' : 'api.mailgun.net'; + $domain = (string) $this->settings->get('mailgun.domain', ''); + $endpoint = sprintf('https://%s/v3/%s/messages', $host, rawurlencode($domain)); + $body = http_build_query([ + 'from' => (string) $this->settings->get('mailgun.from', ''), + 'to' => $recipient, + 'subject' => $subject, + 'html' => $html, + ]); + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => [ + 'Authorization: Basic ' . base64_encode('api:' . $this->settings->get('mailgun.api_key', '')), + 'Content-Type: application/x-www-form-urlencoded', + 'Content-Length: ' . strlen($body), + ], + 'content' => $body, + 'ignore_errors' => true, + 'timeout' => 15, + ], + ]); + $response = @file_get_contents($endpoint, false, $context); + $statusLine = $http_response_header[0] ?? 'HTTP/1.1 500 Unknown response'; + 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)); + } + return ['status' => $status, 'body' => $response]; + } +} diff --git a/app/ReminderService.php b/app/ReminderService.php new file mode 100644 index 0000000..9fd84fc --- /dev/null +++ b/app/ReminderService.php @@ -0,0 +1,99 @@ +settings->get('modules.alerts', true)) { + return ['sent' => 0, 'skipped' => 0, 'errors' => ['Alerts module is disabled.']]; + } + $times = (array) $this->settings->get('alerts.times', ['09:00']); + if (!$force && !in_array(date('H:i'), $times, true)) { + return ['sent' => 0, 'skipped' => 0, 'errors' => []]; + } + $recipient = (string) $this->settings->get('alerts.email', ''); + $windows = array_map('intval', (array) $this->settings->get('alerts.windows', [7, 3, 1])); + $today = new DateTimeImmutable('today'); + $sent = 0; + $skipped = 0; + $errors = []; + + foreach ($windows as $window) { + $target = $today->modify('+' . max(0, $window) . ' days')->format('Y-m-d'); + $statement = $this->pdo->prepare( + 'SELECT bi.*, c.name AS category_name + FROM budget_items bi + LEFT JOIN categories c ON c.id = bi.category_id + WHERE bi.due_date = :due_date AND bi.status = :status' + ); + $statement->execute(['due_date' => $target, 'status' => 'active']); + foreach ($statement->fetchAll() as $item) { + if ($this->alreadyLogged((int) $item['id'], $today->format('Y-m-d'), $window, $recipient)) { + $skipped++; + continue; + } + try { + $subject = sprintf('%s due in %d day%s', $item['name'], $window, $window === 1 ? '' : 's'); + $html = sprintf( + '

%s

Your %s budget item is due on %s.

Planned amount: %s%.2f

', + htmlspecialchars($subject, ENT_QUOTES), + htmlspecialchars((string) ($item['category_name'] ?: 'uncategorized'), ENT_QUOTES), + htmlspecialchars((string) $item['due_date'], ENT_QUOTES), + htmlspecialchars((string) $this->settings->get('app.currency_symbol', '$'), ENT_QUOTES), + (float) $item['planned_amount'] + ); + $response = $this->mailgun->send($recipient, $subject, $html); + $this->log((int) $item['id'], $today->format('Y-m-d'), $window, $recipient, 'sent', (string) $response['body']); + $sent++; + } catch (\Throwable $exception) { + $this->log((int) $item['id'], $today->format('Y-m-d'), $window, $recipient, 'failed', $exception->getMessage()); + $errors[] = $item['name'] . ': ' . $exception->getMessage(); + } + } + } + return ['sent' => $sent, 'skipped' => $skipped, 'errors' => $errors]; + } + + private function alreadyLogged(int $itemId, string $date, int $window, string $recipient): bool + { + $statement = $this->pdo->prepare( + 'SELECT COUNT(*) FROM alert_logs + WHERE budget_item_id = :item_id AND alert_date = :alert_date + AND window_days = :window_days AND recipient = :recipient' + ); + $statement->execute([ + 'item_id' => $itemId, + 'alert_date' => $date, + 'window_days' => $window, + 'recipient' => $recipient, + ]); + return (int) $statement->fetchColumn() > 0; + } + + private function log(int $itemId, string $date, int $window, string $recipient, string $status, string $response): void + { + $statement = $this->pdo->prepare( + 'INSERT INTO alert_logs (budget_item_id, alert_date, window_days, recipient, status, response) + VALUES (:item_id, :alert_date, :window_days, :recipient, :status, :response)' + ); + $statement->execute([ + 'item_id' => $itemId, + 'alert_date' => $date, + 'window_days' => $window, + 'recipient' => $recipient, + 'status' => $status, + 'response' => $response, + ]); + } +} diff --git a/app/Schema.php b/app/Schema.php new file mode 100644 index 0000000..a6d263b --- /dev/null +++ b/app/Schema.php @@ -0,0 +1,276 @@ + */ + public static function statements(string $driver): array + { + return $driver === 'mysql' ? self::mysql() : self::sqlite(); + } + + /** @return list */ + private static function sqlite(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS settings ( + setting_key TEXT PRIMARY KEY, + setting_value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )', + 'CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + totp_secret TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )', + 'CREATE TABLE IF NOT EXISTS monthly_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL UNIQUE, + starting_income NUMERIC NOT NULL DEFAULT 0, + rollover_in NUMERIC NOT NULL DEFAULT 0, + rolling_enabled INTEGER NOT NULL DEFAULT 1, + rollover_source_month TEXT, + notes TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )', + 'CREATE TABLE IF NOT EXISTS income_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL, + label TEXT NOT NULL, + amount NUMERIC NOT NULL, + received_on TEXT NOT NULL, + withholding_amount NUMERIC NOT NULL DEFAULT 0, + notes TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )', + 'CREATE INDEX IF NOT EXISTS idx_income_month ON income_entries(month)', + 'CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT NOT NULL DEFAULT "#7c5cff", + monthly_limit NUMERIC NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )', + 'CREATE TABLE IF NOT EXISTS budget_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL, + category_id INTEGER, + name TEXT NOT NULL, + planned_amount NUMERIC NOT NULL DEFAULT 0, + due_date TEXT, + recurrence TEXT NOT NULL DEFAULT "none", + series_key TEXT, + status TEXT NOT NULL DEFAULT "active", + notes TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL + )', + 'CREATE INDEX IF NOT EXISTS idx_budget_items_month ON budget_items(month)', + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_items_series_month ON budget_items(series_key, month) WHERE series_key IS NOT NULL', + 'CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL, + budget_item_id INTEGER, + category_id INTEGER, + merchant TEXT NOT NULL, + amount NUMERIC NOT NULL, + transacted_on TEXT NOT NULL, + dead_pull_amount NUMERIC NOT NULL DEFAULT 0, + notes TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (budget_item_id) REFERENCES budget_items(id) ON DELETE SET NULL, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL + )', + 'CREATE INDEX IF NOT EXISTS idx_transactions_month ON transactions(month)', + 'CREATE TABLE IF NOT EXISTS dead_account_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL, + kind TEXT NOT NULL, + amount NUMERIC NOT NULL, + source_type TEXT, + source_id INTEGER, + notes TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )', + 'CREATE INDEX IF NOT EXISTS idx_dead_month ON dead_account_entries(month)', + 'CREATE INDEX IF NOT EXISTS idx_dead_source ON dead_account_entries(source_type, source_id)', + 'CREATE TABLE IF NOT EXISTS calendar_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL, + title TEXT NOT NULL, + event_date TEXT NOT NULL, + event_type TEXT NOT NULL DEFAULT "note", + notes TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )', + 'CREATE TABLE IF NOT EXISTS alert_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + budget_item_id INTEGER NOT NULL, + alert_date TEXT NOT NULL, + window_days INTEGER NOT NULL, + recipient TEXT NOT NULL, + status TEXT NOT NULL, + response TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (budget_item_id, alert_date, window_days, recipient), + FOREIGN KEY (budget_item_id) REFERENCES budget_items(id) ON DELETE CASCADE + )', + 'CREATE TABLE IF NOT EXISTS visits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + route TEXT NOT NULL, + visitor_hash TEXT NOT NULL, + visited_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )', + 'CREATE INDEX IF NOT EXISTS idx_visits_date ON visits(visited_at)', + 'CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT NOT NULL, + entity_type TEXT, + entity_id INTEGER, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL + )', + ]; + } + + /** @return list */ + private static function mysql(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS settings ( + setting_key VARCHAR(191) PRIMARY KEY, + setting_value LONGTEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + 'CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + totp_secret VARCHAR(64) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + 'CREATE TABLE IF NOT EXISTS monthly_plans ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + month CHAR(7) NOT NULL UNIQUE, + starting_income DECIMAL(14,2) NOT NULL DEFAULT 0, + rollover_in DECIMAL(14,2) NOT NULL DEFAULT 0, + rolling_enabled TINYINT(1) NOT NULL DEFAULT 1, + rollover_source_month CHAR(7) NULL, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + 'CREATE TABLE IF NOT EXISTS income_entries ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + month CHAR(7) NOT NULL, + label VARCHAR(255) NOT NULL, + amount DECIMAL(14,2) NOT NULL, + received_on DATE NOT NULL, + withholding_amount DECIMAL(14,2) NOT NULL DEFAULT 0, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_income_month (month) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + 'CREATE TABLE IF NOT EXISTS categories ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(191) NOT NULL UNIQUE, + color VARCHAR(16) NOT NULL DEFAULT "#7c5cff", + monthly_limit DECIMAL(14,2) NOT NULL DEFAULT 0, + active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + 'CREATE TABLE IF NOT EXISTS budget_items ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + month CHAR(7) NOT NULL, + category_id BIGINT UNSIGNED NULL, + name VARCHAR(255) NOT NULL, + planned_amount DECIMAL(14,2) NOT NULL DEFAULT 0, + due_date DATE NULL, + recurrence VARCHAR(16) NOT NULL DEFAULT "none", + series_key VARCHAR(64) NULL, + status VARCHAR(24) NOT NULL DEFAULT "active", + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_budget_items_month (month), + UNIQUE KEY idx_budget_items_series_month (series_key, month), + CONSTRAINT fk_item_category FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + 'CREATE TABLE IF NOT EXISTS transactions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + month CHAR(7) NOT NULL, + budget_item_id BIGINT UNSIGNED NULL, + category_id BIGINT UNSIGNED NULL, + merchant VARCHAR(255) NOT NULL, + amount DECIMAL(14,2) NOT NULL, + transacted_on DATE NOT NULL, + dead_pull_amount DECIMAL(14,2) NOT NULL DEFAULT 0, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_transactions_month (month), + CONSTRAINT fk_transaction_item FOREIGN KEY (budget_item_id) REFERENCES budget_items(id) ON DELETE SET NULL, + CONSTRAINT fk_transaction_category FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + 'CREATE TABLE IF NOT EXISTS dead_account_entries ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + month CHAR(7) NOT NULL, + kind VARCHAR(32) NOT NULL, + amount DECIMAL(14,2) NOT NULL, + source_type VARCHAR(32) NULL, + source_id BIGINT NULL, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_dead_month (month), + INDEX idx_dead_source (source_type, source_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + 'CREATE TABLE IF NOT EXISTS calendar_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + month CHAR(7) NOT NULL, + title VARCHAR(255) NOT NULL, + event_date DATE NOT NULL, + event_type VARCHAR(24) NOT NULL DEFAULT "note", + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + 'CREATE TABLE IF NOT EXISTS alert_logs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + budget_item_id BIGINT UNSIGNED NOT NULL, + alert_date DATE NOT NULL, + window_days INT NOT NULL, + recipient VARCHAR(255) NOT NULL, + status VARCHAR(24) NOT NULL, + response TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY alert_unique (budget_item_id, alert_date, window_days, recipient), + CONSTRAINT fk_alert_item FOREIGN KEY (budget_item_id) REFERENCES budget_items(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + 'CREATE TABLE IF NOT EXISTS visits ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + route VARCHAR(191) NOT NULL, + visitor_hash VARCHAR(64) NOT NULL, + visited_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_visits_date (visited_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + 'CREATE TABLE IF NOT EXISTS audit_logs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NULL, + action VARCHAR(191) NOT NULL, + entity_type VARCHAR(64) NULL, + entity_id BIGINT NULL, + metadata LONGTEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + ]; + } +} diff --git a/app/Settings.php b/app/Settings.php new file mode 100644 index 0000000..e35fb1f --- /dev/null +++ b/app/Settings.php @@ -0,0 +1,96 @@ + 'Neon Ledger', + 'app.currency_symbol' => '$', + 'app.timezone' => 'America/New_York', + 'theme.default' => 'dark', + 'modules.income' => true, + 'modules.bills' => true, + 'modules.calendar' => true, + 'modules.dead_account' => true, + 'modules.comparisons' => true, + 'modules.alerts' => true, + 'modules.import_export' => true, + 'dead.income_mode' => 'percent', + 'dead.income_value' => 0, + 'dead.transaction_mode' => 'fixed', + 'dead.transaction_value' => 0, + 'dead.hide_balance' => true, + 'alerts.email' => '', + 'alerts.windows' => [7, 3, 1], + 'alerts.times' => ['09:00'], + 'mailgun.api_key' => '', + 'mailgun.domain' => '', + 'mailgun.region' => 'us', + 'mailgun.from' => 'Budget Planner ', + 'security.2fa_required' => false, + ]; + + public function __construct(private PDO $pdo) + { + $this->seed(); + } + + public function get(string $key, mixed $fallback = null): mixed + { + $statement = $this->pdo->prepare('SELECT setting_value FROM settings WHERE setting_key = :key'); + $statement->execute(['key' => $key]); + $value = $statement->fetchColumn(); + if ($value === false) { + return self::DEFAULTS[$key] ?? $fallback; + } + $decoded = json_decode((string) $value, true); + return json_last_error() === JSON_ERROR_NONE ? $decoded : $value; + } + + public function set(string $key, mixed $value): void + { + $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($encoded === false) { + throw new \InvalidArgumentException('Setting value cannot be encoded.'); + } + $sql = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql' + ? 'INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = CURRENT_TIMESTAMP' + : 'INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value) + ON CONFLICT(setting_key) DO UPDATE SET setting_value = excluded.setting_value, updated_at = CURRENT_TIMESTAMP'; + $statement = $this->pdo->prepare($sql); + $statement->execute(['key' => $key, 'value' => $encoded]); + } + + /** @return array */ + public function all(): array + { + $settings = self::DEFAULTS; + foreach ($this->pdo->query('SELECT setting_key, setting_value FROM settings')->fetchAll() as $row) { + $decoded = json_decode((string) $row['setting_value'], true); + $settings[$row['setting_key']] = json_last_error() === JSON_ERROR_NONE ? $decoded : $row['setting_value']; + } + return $settings; + } + + public function module(string $name): bool + { + return (bool) $this->get('modules.' . $name, true); + } + + private function seed(): void + { + $count = (int) $this->pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn(); + if ($count > 0) { + return; + } + foreach (self::DEFAULTS as $key => $value) { + $this->set($key, $value); + } + } +} diff --git a/app/Support.php b/app/Support.php new file mode 100644 index 0000000..b9205e1 --- /dev/null +++ b/app/Support.php @@ -0,0 +1,79 @@ + $type, 'message' => $message]; + } + + public static function pullFlash(): array + { + $messages = $_SESSION['flash'] ?? []; + unset($_SESSION['flash']); + return is_array($messages) ? $messages : []; + } + + public static function audit(PDO $pdo, ?int $userId, string $action, ?string $entityType = null, ?int $entityId = null, array $metadata = []): void + { + $statement = $pdo->prepare( + 'INSERT INTO audit_logs (user_id, action, entity_type, entity_id, metadata) + VALUES (:user_id, :action, :entity_type, :entity_id, :metadata)' + ); + $statement->execute([ + 'user_id' => $userId, + 'action' => $action, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'metadata' => $metadata === [] ? null : json_encode($metadata, JSON_UNESCAPED_SLASHES), + ]); + } + + public static function trackVisit(PDO $pdo, string $route): void + { + if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + return; + } + $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? 'local'); + $agent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? 'unknown'); + $hash = hash('sha256', date('Y-m-d') . '|' . $ip . '|' . $agent); + $statement = $pdo->prepare('INSERT INTO visits (route, visitor_hash) VALUES (:route, :visitor_hash)'); + $statement->execute(['route' => $route, 'visitor_hash' => $hash]); + } +} diff --git a/app/Totp.php b/app/Totp.php new file mode 100644 index 0000000..1ab6198 --- /dev/null +++ b/app/Totp.php @@ -0,0 +1,77 @@ + true, + 'samesite' => 'Lax', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + ]); + session_start(); +} + +$database = new Database(APP_ROOT . '/config/database.json', APP_ROOT); +$pdo = $database->pdo(); +$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); diff --git a/config/database.json b/config/database.json new file mode 100644 index 0000000..deb960f --- /dev/null +++ b/config/database.json @@ -0,0 +1,12 @@ +{ + "driver": "sqlite", + "sqlite_path": "storage/database/budget.sqlite", + "mysql": { + "host": "127.0.0.1", + "port": 3306, + "database": "budget_planner", + "username": "", + "password": "", + "charset": "utf8mb4" + } +} diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..ca95387 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,8 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] + + + Require all denied + diff --git a/public/assets/app.css b/public/assets/app.css new file mode 100644 index 0000000..4e3df07 --- /dev/null +++ b/public/assets/app.css @@ -0,0 +1,518 @@ +:root { + --bg: #080a12; + --bg-soft: #0e1120; + --surface: rgba(19, 22, 39, .86); + --surface-solid: #131627; + --surface-2: #191d32; + --border: rgba(255, 255, 255, .09); + --border-strong: rgba(255, 255, 255, .16); + --text: #f4f5fb; + --muted: #959bb2; + --muted-2: #68708b; + --purple: #8b5cf6; + --purple-bright: #a978ff; + --blue: #2ea9ff; + --pink: #ff4fa3; + --green: #43f5a4; + --red: #ff5364; + --orange: #ff9f43; + --shadow: 0 24px 70px rgba(0, 0, 0, .28); + --radius: 20px; + --sidebar-width: 248px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color-scheme: dark; +} + +[data-theme="light"] { + --bg: #eef0f8; + --bg-soft: #e6e9f3; + --surface: rgba(255, 255, 255, .9); + --surface-solid: #fff; + --surface-2: #f5f6fb; + --border: rgba(30, 35, 60, .1); + --border-strong: rgba(30, 35, 60, .18); + --text: #181b2d; + --muted: #697087; + --muted-2: #8b91a3; + --shadow: 0 20px 50px rgba(41, 45, 75, .11); + color-scheme: light; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + min-width: 320px; + color: var(--text); + background: + radial-gradient(circle at 75% -10%, rgba(139, 92, 246, .12), transparent 32rem), + radial-gradient(circle at 15% 95%, rgba(46, 169, 255, .08), transparent 30rem), + var(--bg); + font-size: 14px; + line-height: 1.5; +} +button, input, select, textarea { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +a { color: inherit; text-decoration: none; } +button { color: inherit; } +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: 0; font-size: clamp(1.35rem, 2.2vw, 1.8rem); line-height: 1.15; letter-spacing: -.035em; } +h2 { margin-bottom: 0; font-size: 1.05rem; letter-spacing: -.02em; } +small { color: var(--muted); } +code { color: var(--green); background: rgba(67, 245, 164, .08); padding: .15rem .35rem; border-radius: 5px; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; } +.muted { color: var(--muted); } +.green { color: var(--green) !important; } +.pink { color: var(--pink) !important; } +.red { color: var(--red) !important; } +.orange { color: var(--orange) !important; } +.eyebrow { + margin-bottom: .35rem; + color: var(--muted); + font-size: .69rem; + font-weight: 800; + letter-spacing: .13em; + text-transform: uppercase; +} + +.app-shell { min-height: 100vh; } +.sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 40; + display: flex; + width: var(--sidebar-width); + flex-direction: column; + padding: 24px 16px; + border-right: 1px solid var(--border); + background: rgba(9, 11, 20, .88); + backdrop-filter: blur(24px); +} +[data-theme="light"] .sidebar { background: rgba(247, 248, 252, .9); } +.brand { display: flex; align-items: center; gap: 11px; padding: 0 8px 26px; } +.brand-mark { + display: grid; + width: 39px; + height: 39px; + flex: 0 0 39px; + place-items: center; + border: 1px solid rgba(169, 120, 255, .55); + border-radius: 12px; + color: #fff; + background: linear-gradient(145deg, var(--purple), #5a35ca); + box-shadow: 0 0 25px rgba(139, 92, 246, .32); + font-size: .72rem; + font-weight: 900; + letter-spacing: -.04em; +} +.brand strong { display: block; font-size: .92rem; } +.brand small { display: block; margin-top: 1px; font-size: .65rem; } +.nav { display: grid; gap: 5px; } +.nav a { + display: flex; + align-items: center; + gap: 11px; + min-height: 42px; + padding: 9px 11px; + border-radius: 11px; + color: var(--muted); + font-size: .8rem; + font-weight: 650; + transition: .18s ease; +} +.nav a span { display: grid; width: 22px; place-items: center; color: var(--muted-2); font-size: 1rem; } +.nav a:hover { color: var(--text); background: var(--surface-2); } +.nav a.active { + color: #fff; + background: linear-gradient(90deg, rgba(139, 92, 246, .3), rgba(139, 92, 246, .08)); + box-shadow: inset 3px 0 var(--purple-bright); +} +[data-theme="light"] .nav a.active { color: #5631b9; } +.nav a.active span { color: var(--purple-bright); } +.nav-label { margin: 24px 11px 8px; color: var(--muted-2); font-size: .63rem; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; } +.sidebar-footer { display: grid; gap: 5px; margin-top: auto; padding-top: 20px; border-top: 1px solid var(--border); } +.theme-toggle, .text-button { + width: 100%; + padding: 9px 11px; + border: 0; + border-radius: 10px; + color: var(--muted); + background: transparent; + text-align: left; + cursor: pointer; +} +.theme-toggle:hover, .text-button:hover { color: var(--text); background: var(--surface-2); } +.theme-toggle span:first-child { display: inline-block; width: 24px; color: var(--orange); } + +.main { min-height: 100vh; margin-left: var(--sidebar-width); } +.topbar { + display: flex; + min-height: 92px; + align-items: center; + gap: 18px; + padding: 20px clamp(22px, 4vw, 52px); + border-bottom: 1px solid var(--border); + background: rgba(8, 10, 18, .55); + backdrop-filter: blur(18px); +} +[data-theme="light"] .topbar { background: rgba(238, 240, 248, .72); } +.topbar > div:nth-child(2) { flex: 1; } +.topbar .eyebrow { margin-bottom: .25rem; } +.menu-button { display: none; border: 0; background: transparent; font-size: 1.3rem; cursor: pointer; } +.month-switcher { + display: flex; + align-items: center; + gap: 6px; + padding: 5px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); +} +.month-switcher a { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 8px; color: var(--muted); } +.month-switcher a:hover { color: var(--text); background: var(--surface-2); } +.month-switcher input { width: 143px; border: 0; color: var(--text); background: transparent; font-weight: 750; text-align: center; } +.page-content { display: grid; gap: 22px; padding: 28px clamp(22px, 4vw, 52px) 60px; } + +.flash { + display: flex; + align-items: center; + gap: 10px; + margin: 18px clamp(22px, 4vw, 52px) -6px; + padding: 12px 15px; + border: 1px solid; + border-radius: 12px; + font-size: .79rem; +} +.flash > span { display: grid; width: 23px; height: 23px; place-items: center; border-radius: 50%; font-weight: 900; } +.flash.success { border-color: rgba(67, 245, 164, .3); color: var(--green); background: rgba(67, 245, 164, .07); } +.flash.error { border-color: rgba(255, 83, 100, .3); color: var(--red); background: rgba(255, 83, 100, .08); } +.flash button { margin-left: auto; border: 0; color: inherit; background: transparent; cursor: pointer; } + +.card { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + backdrop-filter: blur(16px); +} +.glow-purple { box-shadow: var(--shadow), inset 0 0 50px rgba(139, 92, 246, .06); } +.glow-blue { box-shadow: var(--shadow), inset 0 0 50px rgba(46, 169, 255, .06); } +.glow-green { box-shadow: var(--shadow), inset 0 0 50px rgba(67, 245, 164, .06); } +.hero-grid { display: grid; grid-template-columns: 1.75fr repeat(3, 1fr); gap: 16px; } +.hero-balance { position: relative; display: flex; min-height: 164px; align-items: center; justify-content: space-between; overflow: hidden; padding: 24px 27px; } +.hero-balance::after { position: absolute; width: 180px; height: 180px; right: -60px; bottom: -110px; border-radius: 50%; background: var(--purple); filter: blur(45px); opacity: .17; content: ""; } +.hero-amount { margin: 7px 0 4px; font-size: clamp(2rem, 3.5vw, 3rem); font-weight: 850; letter-spacing: -.065em; } +.balance-ring { + --angle: calc(var(--progress) * 3.6deg); + position: relative; + z-index: 1; + display: grid; + width: 92px; + height: 92px; + flex: 0 0 92px; + place-content: center; + border-radius: 50%; + background: radial-gradient(circle 35px, var(--surface-solid) 97%, transparent 100%), conic-gradient(var(--purple-bright) var(--angle), rgba(255,255,255,.08) 0); + text-align: center; +} +.balance-ring span { font-size: 1.1rem; font-weight: 850; line-height: 1; } +.balance-ring small { margin-top: 3px; font-size: .63rem; text-transform: uppercase; } +.metric-card { position: relative; min-height: 164px; padding: 22px; overflow: hidden; } +.metric-card p { margin: 16px 0 5px; color: var(--muted); font-size: .72rem; font-weight: 700; } +.metric-card strong { display: block; font-size: 1.45rem; letter-spacing: -.045em; } +.metric-card small { display: block; margin-top: 6px; font-size: .65rem; } +.metric-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 10px; font-size: .9rem; font-weight: 900; } +.metric-icon.blue { color: var(--blue); background: rgba(46, 169, 255, .12); } +.metric-icon.pink { color: var(--pink); background: rgba(255, 79, 163, .12); } +.metric-icon.orange { color: var(--orange); background: rgba(255, 159, 67, .12); } +.metric-icon.green { color: var(--green); background: rgba(67, 245, 164, .12); } + +.content-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; } +.span-2 { grid-column: span 2; } +.stack { display: grid; align-content: start; gap: 20px; } +.content-grid .card, .calendar-card, .page-content > .card { padding: 22px; } +.card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 15px; margin-bottom: 20px; } +.card-heading .eyebrow { margin-bottom: 3px; } +.button { + display: inline-flex; + min-height: 40px; + align-items: center; + justify-content: center; + padding: 9px 16px; + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text); + background: var(--surface-2); + font-size: .77rem; + font-weight: 800; + cursor: pointer; + transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; +} +.button:hover { transform: translateY(-1px); border-color: var(--border-strong); } +.button.primary { border-color: rgba(139, 92, 246, .45); color: #fff; background: linear-gradient(135deg, var(--purple), #6841d6); box-shadow: 0 9px 24px rgba(139, 92, 246, .22); } +.button.secondary { background: transparent; } +.button.small { min-height: 33px; padding: 6px 11px; font-size: .7rem; } +.button.full { width: 100%; } +.text-link { padding: 0; border: 0; color: var(--purple-bright); background: none; font-size: .73rem; font-weight: 750; cursor: pointer; } +.text-link:hover { text-decoration: underline; } +.icon-button { display: grid; width: 31px; height: 31px; flex: 0 0 31px; place-items: center; border: 1px solid var(--border); border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; } +.icon-button:hover { color: var(--text); border-color: var(--border-strong); } +.icon-button.danger:hover { color: var(--red); border-color: rgba(255, 83, 100, .35); background: rgba(255, 83, 100, .07); } + +.budget-list { display: grid; } +.budget-row { display: flex; align-items: center; gap: 13px; padding: 15px 0; border-top: 1px solid var(--border); } +.budget-row:first-child { padding-top: 2px; border-top: 0; } +.category-dot { width: 9px; height: 38px; flex: 0 0 9px; border-radius: 9px; background: var(--dot); box-shadow: 0 0 13px color-mix(in srgb, var(--dot), transparent 50%); } +.budget-main { min-width: 0; flex: 1; } +.budget-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.budget-title strong { overflow: hidden; font-size: .8rem; text-overflow: ellipsis; white-space: nowrap; } +.budget-title span, .budget-meta { color: var(--muted); font-size: .63rem; } +.progress-track { height: 5px; margin: 9px 0 6px; overflow: hidden; border-radius: 5px; background: rgba(255,255,255,.07); } +[data-theme="light"] .progress-track { background: rgba(30,35,60,.08); } +.progress-track span { display: block; height: 100%; border-radius: inherit; background: var(--bar); box-shadow: 0 0 12px color-mix(in srgb, var(--bar), transparent 55%); } +.budget-meta { display: flex; justify-content: space-between; } +.budget-row .target { width: 80px; font-size: .79rem; text-align: right; } +.trend-chart { display: block; width: 100%; max-width: 100%; } +.trend-chart.large { min-height: 260px; } +.accent-card { border-color: rgba(139, 92, 246, .23); } +.stats-card { background: linear-gradient(135deg, rgba(46,169,255,.09), rgba(139,92,246,.06)), var(--surface); } +.stat-trio { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; text-align: center; } +.stat-trio div { padding: 12px 4px; border: 1px solid var(--border); border-radius: 11px; background: rgba(255,255,255,.025); } +.stat-trio strong, .stat-trio span { display: block; } +.stat-trio strong { color: var(--blue); font-size: 1.25rem; } +.stat-trio span { margin-top: 2px; color: var(--muted); font-size: .58rem; } +.mini-list { display: grid; } +.mini-list > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 0; border-top: 1px solid var(--border); } +.mini-list > div:first-child { padding-top: 0; border-top: 0; } +.mini-list strong, .mini-list small { display: block; font-size: .72rem; } +.mini-list small { margin-top: 2px; font-size: .58rem; } + +label { display: grid; gap: 6px; color: var(--muted); font-size: .68rem; font-weight: 750; } +input, select, textarea { + width: 100%; + min-height: 41px; + padding: 9px 11px; + border: 1px solid var(--border); + border-radius: 9px; + outline: none; + color: var(--text); + background: var(--surface-2); + transition: border-color .15s ease, box-shadow .15s ease; +} +textarea { min-height: auto; resize: vertical; } +input:focus, select:focus, textarea:focus { border-color: rgba(139, 92, 246, .65); box-shadow: 0 0 0 3px rgba(139, 92, 246, .1); } +input[type="color"] { padding: 5px; } +input[type="checkbox"] { width: 17px; height: 17px; min-height: 17px; accent-color: var(--purple); } +.money-input { position: relative; } +.money-input span { position: absolute; z-index: 1; left: 11px; top: 50%; color: var(--muted); transform: translateY(-50%); } +.money-input input { padding-left: 27px; } +.stack-form { display: grid; gap: 14px; } +.stack-form.compact { gap: 11px; } +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.form-grid .span-2 { grid-column: 1 / -1; } +.form-note { margin: 13px 0 0; color: var(--muted); font-size: .65rem; } +.check-row { display: flex; align-items: flex-start; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: rgba(255,255,255,.025); cursor: pointer; } +.check-row input { margin-top: 2px; } +.check-row span, .check-row strong, .check-row small { display: block; } +.check-row strong { color: var(--text); font-size: .72rem; } +.check-row small { margin-top: 2px; font-weight: 500; } +.check-row.slim { align-items: center; padding: 4px 0; border: 0; background: transparent; } +.check-row.slim span { color: var(--muted); font-size: .68rem; } +.inline-form { display: flex; } +.top-gap { margin-top: 11px; } +.inline-fields { display: grid; grid-template-columns: 1.2fr .35fr 1fr auto; align-items: end; gap: 12px; } +.empty-state { padding: 30px 15px; border: 1px dashed var(--border-strong); border-radius: 14px; color: var(--muted); text-align: center; } +.empty-state strong { color: var(--text); } +.empty-state p { margin: 5px 0 0; font-size: .72rem; } +.empty-state.compact { padding: 20px 10px; } + +dialog { width: min(570px, calc(100vw - 30px)); padding: 0; border: 0; border-radius: 20px; color: var(--text); background: transparent; box-shadow: 0 30px 100px rgba(0,0,0,.55); } +dialog::backdrop { background: rgba(3, 4, 9, .78); backdrop-filter: blur(6px); } +.dialog-card { padding: 24px; border: 1px solid var(--border-strong); border-radius: 20px; background: var(--surface-solid); } +.dialog-card .card-heading { margin-bottom: 19px; } + +.summary-strip { display: grid; grid-template-columns: repeat(3, 1fr) auto; align-items: center; gap: 12px; padding: 17px 20px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); } +.summary-strip div { padding-right: 18px; border-right: 1px solid var(--border); } +.summary-strip span, .summary-strip strong { display: block; } +.summary-strip span { color: var(--muted); font-size: .62rem; } +.summary-strip strong { margin-top: 2px; font-size: 1rem; } +.search-box { position: relative; width: min(240px, 100%); } +.search-box span { position: absolute; z-index: 1; left: 10px; top: 50%; transform: translateY(-50%); } +.search-box input { min-height: 34px; padding: 6px 9px 6px 29px; font-size: .68rem; } +.table-wrap { width: 100%; overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th { padding: 10px; border-bottom: 1px solid var(--border-strong); color: var(--muted); font-size: .61rem; letter-spacing: .08em; text-align: left; text-transform: uppercase; white-space: nowrap; } +td { padding: 13px 10px; border-bottom: 1px solid var(--border); font-size: .72rem; vertical-align: middle; } +tbody tr:last-child td { border-bottom: 0; } +td small { display: block; margin-top: 3px; font-size: .59rem; } +.pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 20px; background: var(--surface-2); font-size: .62rem; } +.pill i, .category-bars i { width: 6px; height: 6px; border-radius: 50%; background: var(--dot); } + +.calendar-toolbar { display: flex; align-items: center; justify-content: space-between; } +.calendar-toolbar p:last-child { margin-bottom: 0; font-size: .72rem; } +.calendar-card { padding: 0 !important; overflow: hidden; } +.calendar-weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } +.calendar-weekdays { border-bottom: 1px solid var(--border); background: var(--surface-2); } +.calendar-weekdays span { padding: 11px; color: var(--muted); font-size: .62rem; font-weight: 800; text-align: center; text-transform: uppercase; } +.calendar-day { position: relative; min-height: 130px; padding: 9px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); } +.calendar-day:nth-child(7n) { border-right: 0; } +.calendar-day.blank { background: rgba(0,0,0,.08); } +.day-number { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; color: var(--muted); font-size: .67rem; font-weight: 800; } +.calendar-day.today .day-number { color: #fff; background: var(--purple); box-shadow: 0 0 16px rgba(139,92,246,.42); } +.calendar-event { display: flex; align-items: flex-start; gap: 5px; margin-top: 6px; padding: 6px 7px; border-left: 2px solid var(--purple); border-radius: 6px; background: rgba(139,92,246,.1); font-size: .58rem; } +.calendar-event.bill { border-color: var(--pink); background: rgba(255,79,163,.09); } +.calendar-event.payday { border-color: var(--green); background: rgba(67,245,164,.08); } +.calendar-event.deadline { border-color: var(--orange); background: rgba(255,159,67,.08); } +.calendar-event > div { min-width: 0; flex: 1; } +.calendar-event strong, .calendar-event small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.calendar-event button { padding: 0; border: 0; color: var(--muted); background: transparent; cursor: pointer; } + +.comparison-grid { grid-template-columns: 2fr 1fr; } +.comparison-metrics { grid-template-columns: repeat(4, 1fr); } +.category-bars { display: grid; gap: 18px; } +.category-bars p { display: flex; justify-content: space-between; margin-bottom: 0; font-size: .68rem; } +.category-bars p span { display: flex; align-items: center; gap: 7px; } +.category-bars small { display: block; margin-top: -1px; font-size: .58rem; } + +.dead-hero { display: flex; min-height: 180px; align-items: center; justify-content: space-between; gap: 30px; padding: 28px 32px; background: linear-gradient(110deg, rgba(46,169,255,.08), rgba(139,92,246,.08)), var(--surface); } +.dead-hero h2 { font-size: 1.45rem; } +.dead-hero p:last-child { max-width: 570px; margin: 10px 0 0; color: var(--muted); } +.visible-balance, .concealed-balance { min-width: 220px; font-size: 2rem; font-weight: 850; letter-spacing: -.05em; text-align: center; } +.concealed-balance { padding: 20px; border: 1px solid rgba(46,169,255,.28); border-radius: 16px; color: var(--blue); background: rgba(46,169,255,.07); cursor: pointer; } +.concealed-balance .revealed { display: none; } +.concealed-balance.reveal .masked { display: none; } +.concealed-balance.reveal .revealed { display: block; } +.concealed-balance small { display: block; margin-top: 4px; font-size: .58rem; font-weight: 650; letter-spacing: 0; } +.dead-grid { grid-template-columns: repeat(2, 1fr); } +.rule-list { display: grid; gap: 15px; } +.rule-list > div { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid var(--border); border-radius: 12px; background: rgba(255,255,255,.02); } +.rule-list p, .rule-list strong, .rule-list small { display: block; margin: 0; } +.rule-list strong { font-size: .72rem; } +.rule-list small { margin-top: 3px; font-size: .6rem; } + +.settings-grid { display: grid; grid-template-columns: .7fr repeat(2, 1fr); gap: 20px; align-items: start; } +.settings-nav { position: sticky; top: 20px; display: grid; gap: 5px; padding: 18px !important; } +.settings-nav .eyebrow { padding: 0 8px 7px; } +.settings-nav a { padding: 8px; border-radius: 8px; color: var(--muted); font-size: .7rem; font-weight: 700; } +.settings-nav a:hover { color: var(--text); background: var(--surface-2); } +.toggle-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } +.toggle-card { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: 11px; background: rgba(255,255,255,.02); cursor: pointer; } +.toggle-card span, .toggle-card strong, .toggle-card small { display: block; } +.toggle-card strong { color: var(--text); font-size: .7rem; } +.toggle-card small { margin-top: 2px; font-size: .56rem; font-weight: 500; } +.toggle-card input { position: absolute; opacity: 0; pointer-events: none; } +.toggle-card i { position: relative; width: 35px; height: 20px; flex: 0 0 35px; border-radius: 20px; background: var(--muted-2); transition: .18s; } +.toggle-card i::after { position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; border-radius: 50%; background: #fff; transition: .18s; content: ""; } +.toggle-card input:checked + i { background: var(--purple); box-shadow: 0 0 14px rgba(139,92,246,.3); } +.toggle-card input:checked + i::after { transform: translateX(15px); } +.status-badge { padding: 5px 8px; border-radius: 20px; font-size: .58rem; font-weight: 850; text-transform: uppercase; } +.status-badge.success { color: var(--green); background: rgba(67,245,164,.1); } +.status-badge.warning { color: var(--orange); background: rgba(255,159,67,.1); } +.sticky-save { position: sticky; bottom: 18px; width: 100%; z-index: 5; } +.settings-actions { display: flex; justify-content: flex-end; gap: 10px; } +.setup-secret { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; padding: 23px; border-color: rgba(67,245,164,.3); } +.setup-secret p { margin: 6px 0 0; color: var(--muted); } +.setup-secret > code { padding: 12px 15px; font-size: 1.1rem; letter-spacing: .12em; } +.setup-secret details { grid-column: 1 / -1; } +.wrap-code { display: block; margin-top: 8px; overflow-wrap: anywhere; } + +.data-actions { grid-template-columns: repeat(3, 1fr); } +.export-card { display: flex; min-height: 145px; align-items: center; gap: 16px; padding: 21px; transition: transform .18s ease, border-color .18s ease; } +.export-card:hover { transform: translateY(-3px); border-color: var(--border-strong); } +.export-card > div { min-width: 0; flex: 1; } +.export-card small { display: block; margin-top: 7px; } +.export-card b { font-size: 1.2rem; } +.export-icon { display: grid; width: 48px; height: 48px; flex: 0 0 48px; place-items: center; border-radius: 14px; font-weight: 900; } +.export-card.purple .export-icon { color: var(--purple-bright); background: rgba(139,92,246,.12); } +.export-card.blue .export-icon { color: var(--blue); background: rgba(46,169,255,.12); } +.export-card.pink .export-icon { color: var(--pink); background: rgba(255,79,163,.12); } +.data-grid { grid-template-columns: repeat(2, 1fr); } +.file-drop { padding: 24px; border: 1px dashed var(--border-strong); border-radius: 13px; text-align: center; cursor: pointer; } +.file-drop input { margin-bottom: 9px; background: transparent; } +.file-drop span { display: block; font-size: .62rem; } +.database-facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.database-facts div { padding: 13px; border: 1px solid var(--border); border-radius: 10px; } +.database-facts span, .database-facts strong { display: block; } +.database-facts span { color: var(--muted); font-size: .58rem; } +.database-facts strong { margin-top: 4px; font-size: .7rem; } +.portability-note { display: flex; align-items: center; gap: 14px; } +.portability-note p { margin: 4px 0 0; color: var(--muted); font-size: .7rem; } + +.auth-body { min-height: 100vh; overflow-x: hidden; background: #080a12; color: #f4f5fb; } +.auth-shell { display: grid; min-height: 100vh; grid-template-columns: 1.15fr .85fr; } +.auth-story { position: relative; display: flex; min-height: 100vh; flex-direction: column; justify-content: space-between; overflow: hidden; padding: clamp(30px, 6vw, 78px); background: radial-gradient(circle at 60% 45%, rgba(139,92,246,.18), transparent 27rem), linear-gradient(145deg, #111426, #090b14); } +.auth-story::before { position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(rgba(255,255,255,.04) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px); background-size: 42px 42px; content: ""; mask-image: linear-gradient(to bottom right, black, transparent 75%); } +.auth-story > * { position: relative; z-index: 1; } +.auth-brand { padding: 0; } +.auth-story h1 { max-width: 650px; margin-bottom: 20px; font-size: clamp(3rem, 6vw, 6.5rem); line-height: .92; letter-spacing: -.07em; } +.auth-story > div:nth-child(2) > p:last-child { max-width: 580px; color: #9da3b8; font-size: 1rem; } +.auth-orbit { position: absolute; width: 340px; height: 340px; right: -55px; bottom: -65px; border: 1px solid rgba(169,120,255,.18); border-radius: 50%; } +.auth-orbit span { position: absolute; inset: 35px; border: 1px solid rgba(46,169,255,.18); border-radius: 50%; } +.auth-orbit .orbit-two { inset: 80px; border-color: rgba(255,79,163,.18); } +.auth-orbit .orbit-three { inset: 123px; border-color: rgba(67,245,164,.18); } +.auth-orbit strong { position: absolute; inset: 0; display: grid; place-items: center; color: var(--green); font-size: 3rem; text-shadow: 0 0 30px rgba(67,245,164,.4); } +.auth-panel { display: grid; place-items: center; padding: 35px; background: #0c0e19; } +.auth-panel .flash { width: min(430px, 100%); margin: 0 0 14px; } +.auth-card { width: min(430px, 100%); padding: 32px; border: 1px solid rgba(255,255,255,.09); border-radius: 22px; background: #131627; box-shadow: 0 30px 80px rgba(0,0,0,.35); } +.auth-card h2 { margin-bottom: 8px; font-size: 1.7rem; } +.auth-card > .muted { margin-bottom: 24px; } +.auth-card input, .auth-card select { background: #191d32; } +.otp-input { font-size: 1.6rem; letter-spacing: .4em; text-align: center; } + +@media (max-width: 1180px) { + .hero-grid { grid-template-columns: repeat(3, 1fr); } + .hero-balance { grid-column: 1 / -1; } + .comparison-metrics { grid-template-columns: repeat(2, 1fr); } + .data-actions { grid-template-columns: 1fr; } + .settings-grid { grid-template-columns: 1fr; } + .settings-grid > .span-2 { grid-column: 1; } + .settings-nav { position: static; display: flex; overflow-x: auto; } +} + +@media (max-width: 900px) { + .sidebar { transform: translateX(-100%); transition: transform .2s ease; box-shadow: 20px 0 70px rgba(0,0,0,.4); } + .sidebar.open { transform: translateX(0); } + .main { margin-left: 0; } + .menu-button { display: block; } + .content-grid, .comparison-grid, .dead-grid, .data-grid { grid-template-columns: 1fr; } + .content-grid > .span-2 { grid-column: 1; } + .calendar-day { min-height: 110px; } + .auth-shell { grid-template-columns: 1fr; } + .auth-story { display: none; } + .auth-panel { min-height: 100vh; } +} + +@media (max-width: 650px) { + .topbar { min-height: 78px; padding: 15px 16px; } + .topbar .eyebrow { display: none; } + .month-switcher input { width: 118px; } + .month-switcher a { width: 25px; } + .page-content { padding: 20px 15px 45px; } + .flash { margin-left: 15px; margin-right: 15px; } + .hero-grid, .comparison-metrics { grid-template-columns: 1fr; } + .hero-balance { grid-column: 1; } + .metric-card { min-height: 135px; } + .summary-strip { grid-template-columns: 1fr 1fr; } + .summary-strip div { border-right: 0; } + .summary-strip .button { grid-column: 1 / -1; } + .card-heading { flex-wrap: wrap; } + .search-box { width: 100%; } + .form-grid, .toggle-grid { grid-template-columns: 1fr; } + .form-grid .span-2 { grid-column: 1; } + .inline-fields { grid-template-columns: 1fr; } + .calendar-weekdays span { padding: 8px 2px; font-size: .52rem; } + .calendar-day { min-height: 76px; padding: 4px; } + .calendar-event { padding: 3px; border-left-width: 1px; } + .calendar-event > span, .calendar-event small, .calendar-event form { display: none; } + .calendar-event strong { font-size: .48rem; } + .day-number { width: 20px; height: 20px; } + .dead-hero { align-items: stretch; flex-direction: column; padding: 22px; } + .visible-balance, .concealed-balance { width: 100%; min-width: 0; } + .setup-secret { grid-template-columns: 1fr; } + .setup-secret details { grid-column: 1; } + .setup-secret > code { overflow-x: auto; } + .database-facts { grid-template-columns: 1fr; } + table.responsive, table.responsive tbody, table.responsive tr, table.responsive td { display: block; } + .auth-panel { padding: 18px; } + .auth-card { padding: 24px; } +} diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..14369c5 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1,181 @@ +(() => { + const root = document.documentElement; + const toggle = document.querySelector('[data-theme-toggle]'); + const updateThemeLabel = () => { + const label = document.querySelector('[data-theme-label]'); + if (label) label.textContent = root.dataset.theme === 'dark' ? 'Light mode' : 'Dark mode'; + }; + updateThemeLabel(); + toggle?.addEventListener('click', () => { + root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark'; + localStorage.setItem('budget-theme', root.dataset.theme); + updateThemeLabel(); + document.querySelectorAll('[data-trend-chart]').forEach(drawTrendChart); + }); + + document.querySelector('[data-menu-toggle]')?.addEventListener('click', () => { + document.querySelector('#sidebar')?.classList.toggle('open'); + }); + + document.querySelector('[data-month-picker]')?.addEventListener('change', (event) => { + const picker = event.currentTarget; + const parameters = new URLSearchParams({ route: picker.dataset.route, month: picker.value }); + window.location.href = `?${parameters.toString()}`; + }); + + document.querySelectorAll('[data-dialog-open]').forEach((button) => { + button.addEventListener('click', () => document.getElementById(button.dataset.dialogOpen)?.showModal()); + }); + document.querySelectorAll('[data-dialog-close]').forEach((button) => { + button.addEventListener('click', () => button.closest('dialog')?.close()); + }); + document.querySelectorAll('dialog').forEach((dialog) => { + dialog.addEventListener('click', (event) => { + if (event.target === dialog) dialog.close(); + }); + }); + + document.querySelectorAll('[data-confirm]').forEach((form) => { + form.addEventListener('submit', (event) => { + if (!window.confirm(form.dataset.confirm)) event.preventDefault(); + }); + }); + + document.querySelectorAll('.flash button').forEach((button) => { + button.addEventListener('click', () => button.closest('.flash')?.remove()); + }); + + document.querySelectorAll('[data-table-search]').forEach((input) => { + input.addEventListener('input', () => { + const table = document.getElementById(input.dataset.tableSearch); + const query = input.value.trim().toLowerCase(); + table?.querySelectorAll('tbody tr').forEach((row) => { + row.hidden = query !== '' && !row.textContent.toLowerCase().includes(query); + }); + }); + }); + + const concealed = document.querySelector('[data-concealed-balance]'); + if (concealed) { + let approved = false; + concealed.addEventListener('click', () => { + if (!approved) approved = window.confirm('Reveal the dormant account balance while your pointer remains here?'); + if (approved) { + concealed.querySelector('.revealed').textContent = concealed.dataset.value; + concealed.classList.add('reveal'); + } + }); + concealed.addEventListener('mouseleave', () => { + concealed.classList.remove('reveal'); + approved = false; + }); + } + + function drawTrendChart(canvas) { + let data; + try { + data = JSON.parse(canvas.dataset.trendChart || '[]'); + } catch { + return; + } + if (!data.length) return; + const width = canvas.clientWidth || 600; + const height = Number(canvas.getAttribute('height')) || 220; + const ratio = window.devicePixelRatio || 1; + canvas.width = width * ratio; + canvas.height = height * ratio; + canvas.style.height = `${height}px`; + const context = canvas.getContext('2d'); + context.scale(ratio, ratio); + + const styles = getComputedStyle(document.documentElement); + const text = styles.getPropertyValue('--muted').trim(); + const border = styles.getPropertyValue('--border-strong').trim(); + const purple = styles.getPropertyValue('--purple-bright').trim(); + const pink = styles.getPropertyValue('--pink').trim(); + const padding = { top: 20, right: 12, bottom: 32, left: 48 }; + const plotWidth = width - padding.left - padding.right; + const plotHeight = height - padding.top - padding.bottom; + const maxValue = Math.max(...data.flatMap((row) => [Number(row.gross_resources) || 0, Number(row.expenses) || 0]), 1) * 1.12; + + context.clearRect(0, 0, width, height); + context.font = '10px system-ui'; + context.fillStyle = text; + context.strokeStyle = border; + context.lineWidth = 1; + context.setLineDash([3, 5]); + for (let line = 0; line <= 4; line++) { + const y = padding.top + plotHeight * (line / 4); + context.beginPath(); + context.moveTo(padding.left, y); + context.lineTo(width - padding.right, y); + context.stroke(); + const value = maxValue * (1 - line / 4); + context.fillText(compactMoney(value, canvas.dataset.symbol), 0, y + 3); + } + context.setLineDash([]); + + const pointX = (index) => padding.left + (data.length === 1 ? plotWidth / 2 : plotWidth * index / (data.length - 1)); + const pointY = (value) => padding.top + plotHeight - (Number(value) / maxValue * plotHeight); + + const drawSeries = (key, color, fill) => { + context.beginPath(); + data.forEach((row, index) => { + const x = pointX(index); + const y = pointY(row[key]); + index === 0 ? context.moveTo(x, y) : context.lineTo(x, y); + }); + context.strokeStyle = color; + context.lineWidth = 2.5; + context.lineJoin = 'round'; + context.stroke(); + if (fill) { + context.lineTo(pointX(data.length - 1), padding.top + plotHeight); + context.lineTo(pointX(0), padding.top + plotHeight); + context.closePath(); + const gradient = context.createLinearGradient(0, padding.top, 0, padding.top + plotHeight); + gradient.addColorStop(0, `${color}32`); + gradient.addColorStop(1, `${color}00`); + context.fillStyle = gradient; + context.fill(); + } + data.forEach((row, index) => { + context.beginPath(); + context.arc(pointX(index), pointY(row[key]), 3.5, 0, Math.PI * 2); + context.fillStyle = color; + context.fill(); + }); + }; + + drawSeries('gross_resources', purple, true); + drawSeries('expenses', pink, false); + data.forEach((row, index) => { + const label = new Date(`${row.month}-02T12:00:00`).toLocaleDateString(undefined, { month: 'short' }); + context.fillStyle = text; + context.textAlign = 'center'; + context.fillText(label, pointX(index), height - 9); + }); + context.textAlign = 'left'; + context.fillStyle = purple; + context.fillRect(width - 145, 3, 8, 8); + context.fillStyle = text; + context.fillText('Resources', width - 132, 11); + context.fillStyle = pink; + context.fillRect(width - 70, 3, 8, 8); + context.fillStyle = text; + context.fillText('Spent', width - 57, 11); + } + + function compactMoney(value, symbol = '$') { + if (value >= 1000000) return `${symbol}${(value / 1000000).toFixed(1)}m`; + if (value >= 1000) return `${symbol}${(value / 1000).toFixed(1)}k`; + return `${symbol}${Math.round(value)}`; + } + + document.querySelectorAll('[data-trend-chart]').forEach(drawTrendChart); + let resizeTimer; + window.addEventListener('resize', () => { + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => document.querySelectorAll('[data-trend-chart]').forEach(drawTrendChart), 120); + }); +})(); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..cae6f11 --- /dev/null +++ b/public/index.php @@ -0,0 +1,461 @@ + $route] + $parameters); +} + +function csrf_field(): string +{ + return ''; +} + +function redirect_to(string $route, array $parameters = []): never +{ + header('Location: ' . url($route, $parameters)); + exit; +} + +function render_view(string $view, array $data = [], bool $authLayout = false): never +{ + global $settings, $auth; + extract($data, EXTR_SKIP); + $flashes = Support::pullFlash(); + ob_start(); + require APP_ROOT . '/views/' . $view . '.php'; + $content = (string) ob_get_clean(); + require APP_ROOT . '/views/' . ($authLayout ? 'auth-layout.php' : 'layout.php'); + exit; +} + +$route = (string) ($_GET['route'] ?? ($auth->check() ? 'dashboard' : 'login')); +$month = Support::month((string) ($_GET['month'] ?? $_POST['month'] ?? date('Y-m'))); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = (string) ($_POST['action'] ?? ''); + try { + Support::verifyCsrf($_POST['csrf_token'] ?? null); + + if ($action === 'setup') { + if (!$auth->needsSetup()) { + throw new RuntimeException('Setup has already been completed.'); + } + $email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL); + $password = (string) ($_POST['password'] ?? ''); + if (!$email) { + throw new RuntimeException('Enter a valid administrator email.'); + } + if (strlen($password) < 10 || $password !== (string) ($_POST['password_confirmation'] ?? '')) { + throw new RuntimeException('Use at least 10 characters and make sure both passwords match.'); + } + $secret = $auth->setup((string) $email, $password, isset($_POST['enable_2fa'])); + if ($secret) { + $_SESSION['new_totp_secret'] = $secret; + } + Support::flash('success', 'Administrator account created. Your planner is ready.'); + redirect_to($secret ? 'settings' : 'dashboard'); + } + + if ($action === 'login') { + $result = $auth->attempt((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? '')); + if ($result === 'ok') { + redirect_to('dashboard'); + } + if ($result === '2fa') { + redirect_to('two-factor'); + } + throw new RuntimeException( + $result === 'unconfigured_2fa' + ? 'Two-factor authentication is required but not configured for this account.' + : 'Email or password was not recognized.' + ); + } + + if ($action === 'verify_2fa') { + if (!$auth->verifyTwoFactor((string) ($_POST['code'] ?? ''))) { + throw new RuntimeException('That authentication code is invalid or expired.'); + } + redirect_to('dashboard'); + } + + if (!$auth->check()) { + redirect_to('login'); + } + + if ($action === 'logout') { + $auth->logout(); + header('Location: ' . url('login')); + exit; + } + + if ($action === 'plan.save') { + $budget->savePlan( + $month, + (float) ($_POST['starting_income'] ?? 0), + (float) ($_POST['rollover_in'] ?? 0), + isset($_POST['rolling_enabled']), + (string) ($_POST['notes'] ?? '') + ); + Support::audit($pdo, $auth->id(), 'plan.updated', 'monthly_plan', null, ['month' => $month]); + Support::flash('success', 'Monthly starting point updated.'); + redirect_to('dashboard', ['month' => $month]); + } + + if ($action === 'plan.refresh_rollover') { + $rollover = $budget->refreshRollover($month); + Support::flash('success', 'Rollover refreshed to ' . Support::money($rollover, (string) $settings->get('app.currency_symbol', '$')) . '.'); + redirect_to('dashboard', ['month' => $month]); + } + + if ($action === 'income.add') { + $id = $budget->addIncome( + $month, + (string) ($_POST['label'] ?? ''), + (float) ($_POST['amount'] ?? 0), + (string) ($_POST['received_on'] ?? date('Y-m-d')), + (string) ($_POST['notes'] ?? '') + ); + Support::audit($pdo, $auth->id(), 'income.created', 'income', $id, ['month' => $month]); + Support::flash('success', 'Income added and automatic withholding applied.'); + redirect_to('dashboard', ['month' => $month]); + } + + if ($action === 'income.delete') { + $budget->deleteIncome((int) ($_POST['id'] ?? 0)); + Support::audit($pdo, $auth->id(), 'income.deleted', 'income', (int) ($_POST['id'] ?? 0)); + Support::flash('success', 'Income entry removed.'); + redirect_to('dashboard', ['month' => $month]); + } + + if ($action === 'category.add') { + $id = $budget->addCategory( + (string) ($_POST['name'] ?? ''), + (string) ($_POST['color'] ?? '#7c5cff'), + (float) ($_POST['monthly_limit'] ?? 0) + ); + Support::audit($pdo, $auth->id(), 'category.created', 'category', $id); + Support::flash('success', 'Category created.'); + redirect_to('dashboard', ['month' => $month]); + } + + if ($action === 'item.add') { + $id = $budget->addBudgetItem( + $month, + !empty($_POST['category_id']) ? (int) $_POST['category_id'] : null, + (string) ($_POST['name'] ?? ''), + (float) ($_POST['planned_amount'] ?? 0), + !empty($_POST['due_date']) ? (string) $_POST['due_date'] : null, + (string) ($_POST['recurrence'] ?? 'none'), + (string) ($_POST['notes'] ?? '') + ); + Support::audit($pdo, $auth->id(), 'budget_item.created', 'budget_item', $id, ['month' => $month]); + Support::flash('success', 'Budget item added.'); + redirect_to('dashboard', ['month' => $month]); + } + + if ($action === 'item.delete') { + $budget->deleteBudgetItem((int) ($_POST['id'] ?? 0)); + Support::audit($pdo, $auth->id(), 'budget_item.deleted', 'budget_item', (int) ($_POST['id'] ?? 0)); + Support::flash('success', 'Budget item removed. Existing expenses were preserved.'); + redirect_to('dashboard', ['month' => $month]); + } + + if ($action === 'transaction.add') { + $id = $budget->addTransaction( + $month, + !empty($_POST['budget_item_id']) ? (int) $_POST['budget_item_id'] : null, + !empty($_POST['category_id']) ? (int) $_POST['category_id'] : null, + (string) ($_POST['merchant'] ?? ''), + (float) ($_POST['amount'] ?? 0), + (string) ($_POST['transacted_on'] ?? date('Y-m-d')), + (string) ($_POST['notes'] ?? '') + ); + Support::audit($pdo, $auth->id(), 'transaction.created', 'transaction', $id, ['month' => $month]); + Support::flash('success', 'Expense recorded and budget progress updated.'); + redirect_to('transactions', ['month' => $month]); + } + + if ($action === 'transaction.delete') { + $budget->deleteTransaction((int) ($_POST['id'] ?? 0)); + Support::audit($pdo, $auth->id(), 'transaction.deleted', 'transaction', (int) ($_POST['id'] ?? 0)); + Support::flash('success', 'Expense and its automatic dormant transfer were removed.'); + redirect_to('transactions', ['month' => $month]); + } + + if ($action === 'dead.move') { + $id = $budget->moveDeadAccount( + $month, + (string) ($_POST['direction'] ?? 'deposit'), + (float) ($_POST['amount'] ?? 0), + (string) ($_POST['notes'] ?? '') + ); + Support::audit($pdo, $auth->id(), 'dead_account.moved', 'dead_entry', $id, ['month' => $month]); + Support::flash('success', 'Dormant account transfer recorded.'); + redirect_to('dead-account', ['month' => $month]); + } + + if ($action === 'event.add') { + $id = $budget->addCalendarEvent( + $month, + (string) ($_POST['title'] ?? ''), + (string) ($_POST['event_date'] ?? date('Y-m-d')), + (string) ($_POST['event_type'] ?? 'note'), + (string) ($_POST['notes'] ?? '') + ); + Support::audit($pdo, $auth->id(), 'calendar_event.created', 'calendar_event', $id); + Support::flash('success', 'Calendar event added.'); + redirect_to('calendar', ['month' => $month]); + } + + if ($action === 'event.delete') { + $budget->deleteCalendarEvent((int) ($_POST['id'] ?? 0)); + Support::audit($pdo, $auth->id(), 'calendar_event.deleted', 'calendar_event', (int) ($_POST['id'] ?? 0)); + Support::flash('success', 'Calendar event removed.'); + redirect_to('calendar', ['month' => $month]); + } + + if ($action === 'settings.save') { + $stringSettings = [ + 'app.name', 'app.currency_symbol', 'app.timezone', 'theme.default', + 'dead.income_mode', 'dead.transaction_mode', 'alerts.email', + 'mailgun.domain', 'mailgun.region', 'mailgun.from', + ]; + foreach ($stringSettings as $key) { + if (array_key_exists($key, $_POST)) { + $settings->set($key, trim((string) $_POST[$key])); + } + } + foreach (['dead.income_value', 'dead.transaction_value'] as $key) { + $settings->set($key, max(0, (float) ($_POST[$key] ?? 0))); + } + foreach (['income', 'bills', 'calendar', 'dead_account', 'comparisons', 'alerts', 'import_export'] as $module) { + $settings->set('modules.' . $module, isset($_POST['modules_' . $module])); + } + $settings->set('dead.hide_balance', isset($_POST['dead_hide_balance'])); + $windows = array_values(array_unique(array_filter(array_map( + static fn (string $value): int => max(0, (int) trim($value)), + explode(',', (string) ($_POST['alerts.windows'] ?? '7,3,1')) + ), static fn (int $value): bool => $value <= 365))); + $times = array_values(array_filter(array_map('trim', explode(',', (string) ($_POST['alerts.times'] ?? '09:00'))), static fn (string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time))); + $settings->set('alerts.windows', $windows ?: [7, 3, 1]); + $settings->set('alerts.times', $times ?: ['09:00']); + if (trim((string) ($_POST['mailgun.api_key'] ?? '')) !== '') { + $settings->set('mailgun.api_key', trim((string) $_POST['mailgun.api_key'])); + } elseif (isset($_POST['clear_mailgun_key'])) { + $settings->set('mailgun.api_key', ''); + } + + $requireTwoFactor = isset($_POST['security_2fa_required']); + $user = $auth->user(); + if ($requireTwoFactor && empty($user['totp_secret'])) { + $secret = Totp::generateSecret(); + $statement = $pdo->prepare('UPDATE users SET totp_secret = :secret, updated_at = CURRENT_TIMESTAMP WHERE id = :id'); + $statement->execute(['secret' => $secret, 'id' => $auth->id()]); + $_SESSION['new_totp_secret'] = $secret; + } + $settings->set('security.2fa_required', $requireTwoFactor); + Support::audit($pdo, $auth->id(), 'settings.updated'); + Support::flash('success', 'Planner configuration saved.'); + redirect_to('settings'); + } + + if ($action === '2fa.regenerate') { + $secret = Totp::generateSecret(); + $statement = $pdo->prepare('UPDATE users SET totp_secret = :secret, updated_at = CURRENT_TIMESTAMP WHERE id = :id'); + $statement->execute(['secret' => $secret, 'id' => $auth->id()]); + $_SESSION['new_totp_secret'] = $secret; + Support::audit($pdo, $auth->id(), '2fa.regenerated'); + Support::flash('success', 'A new two-factor secret was generated.'); + redirect_to('settings'); + } + + if ($action === 'alerts.run') { + $runner = new ReminderService($pdo, $settings, new Mailgun($settings)); + $result = $runner->run(true); + $message = sprintf('%d reminder(s) sent; %d skipped.', $result['sent'], $result['skipped']); + if ($result['errors'] !== []) { + $message .= ' ' . implode(' ', $result['errors']); + Support::flash('error', $message); + } else { + Support::flash('success', $message); + } + redirect_to('settings'); + } + + if ($action === 'data.import') { + if (!isset($_FILES['planner_file']) || !is_uploaded_file($_FILES['planner_file']['tmp_name'])) { + throw new RuntimeException('Choose a planner JSON package to import.'); + } + $json = file_get_contents($_FILES['planner_file']['tmp_name']); + if ($json === false) { + throw new RuntimeException('The uploaded package could not be read.'); + } + $exporter = new ExportService($pdo, $settings, $budget); + $result = $exporter->importPackage($json, isset($_POST['replace_existing'])); + Support::audit($pdo, $auth->id(), 'data.imported', null, null, $result); + Support::flash('success', 'Planner package imported successfully.'); + redirect_to('data'); + } + + if ($action === 'database.migrate') { + $config = [ + 'host' => trim((string) ($_POST['host'] ?? '127.0.0.1')), + 'port' => (int) ($_POST['port'] ?? 3306), + 'database' => trim((string) ($_POST['database'] ?? '')), + 'username' => trim((string) ($_POST['username'] ?? '')), + 'password' => (string) ($_POST['password'] ?? ''), + 'charset' => 'utf8mb4', + ]; + if ($config['database'] === '' || $config['username'] === '') { + throw new RuntimeException('MySQL database name and username are required.'); + } + $count = $database->migrateSqliteToMysql($config, APP_ROOT); + Support::flash('success', $count . ' records copied. MySQL mode is active on the next request.'); + redirect_to('data'); + } + + throw new RuntimeException('Unknown action.'); + } catch (Throwable $exception) { + Support::flash('error', $exception->getMessage()); + $fallbackRoute = in_array($route, ['login', 'setup', 'two-factor'], true) ? $route : ($route ?: 'dashboard'); + redirect_to($fallbackRoute, $auth->check() && $month ? ['month' => $month] : []); + } +} + +if ($route === 'logout') { + redirect_to('login'); +} + +if ($auth->needsSetup()) { + render_view('setup', ['title' => 'Create your planner'], true); +} + +if (!$auth->check()) { + if ($route === 'two-factor' && isset($_SESSION['pending_2fa_user'])) { + render_view('two-factor', ['title' => 'Two-factor verification'], true); + } + render_view('login', ['title' => 'Administrator sign in'], true); +} + +Support::trackVisit($pdo, $route); + +if (in_array($route, ['export-json', 'export-html', 'export-pdf'], true)) { + $exporter = new ExportService($pdo, $settings, $budget); + if ($route === 'export-json') { + header('Content-Type: application/json; charset=utf-8'); + header('Content-Disposition: attachment; filename="neon-ledger-' . date('Ymd-His') . '.json"'); + echo $exporter->package(); + exit; + } + if ($route === 'export-html') { + header('Content-Type: text/html; charset=utf-8'); + header('Content-Disposition: attachment; filename="budget-' . $month . '.html"'); + echo $exporter->html($month); + exit; + } + header('Content-Type: application/pdf'); + header('Content-Disposition: attachment; filename="budget-' . $month . '.pdf"'); + echo $exporter->pdf($month); + exit; +} + +$plan = $budget->ensureMonth($month); +$summary = $budget->summary($month); +$symbol = (string) $settings->get('app.currency_symbol', '$'); + +switch ($route) { + case 'transactions': + render_view('transactions', [ + 'title' => 'Expenses', + 'route' => $route, + 'month' => $month, + 'summary' => $summary, + 'items' => $budget->budgetItems($month), + 'categories' => $budget->categories(), + 'transactions' => $budget->transactions($month), + 'symbol' => $symbol, + ]); + case 'calendar': + render_view('calendar', [ + 'title' => 'Bill calendar', + 'route' => $route, + 'month' => $month, + 'entries' => $budget->calendarEntries($month), + 'symbol' => $symbol, + ]); + case 'comparisons': + $year = (int) substr($month, 0, 4); + render_view('comparisons', [ + 'title' => 'Comparisons', + 'route' => $route, + 'month' => $month, + 'comparison' => $budget->comparison($month), + 'yearly' => $budget->yearlySummary($year), + 'categoryStats' => $budget->categoryStats($month), + 'symbol' => $symbol, + ]); + case 'dead-account': + render_view('dead-account', [ + 'title' => 'Dormant account', + 'route' => $route, + 'month' => $month, + 'balance' => $budget->deadBalance(), + 'entries' => $budget->deadEntries(null, 150), + 'hideBalance' => (bool) $settings->get('dead.hide_balance', true), + 'symbol' => $symbol, + ]); + case 'settings': + $user = $auth->user(); + $newSecret = $_SESSION['new_totp_secret'] ?? null; + unset($_SESSION['new_totp_secret']); + render_view('settings', [ + 'title' => 'Settings', + 'route' => $route, + 'month' => $month, + 'allSettings' => $settings->all(), + 'user' => $user, + 'newSecret' => $newSecret, + 'totpUri' => $newSecret ? Totp::uri($newSecret, (string) $user['email'], (string) $settings->get('app.name', 'Neon Ledger')) : null, + 'mailgunConfigured' => (new Mailgun($settings))->configured(), + ]); + case 'data': + render_view('data', [ + 'title' => 'Import & export', + 'route' => $route, + 'month' => $month, + 'databaseDriver' => $database->driver(), + 'databaseConfig' => $database->config(), + ]); + case 'dashboard': + default: + render_view('dashboard', [ + 'title' => 'Command center', + 'route' => 'dashboard', + 'month' => $month, + 'plan' => $plan, + 'summary' => $summary, + 'incomes' => $budget->incomes($month), + 'items' => $budget->budgetItems($month), + 'categories' => $budget->categories(), + 'categoryStats' => $budget->categoryStats($month), + 'comparison' => $budget->comparison($month, 6), + 'visitorStats' => $budget->visitorStats(), + 'deadBalance' => $budget->deadBalance(), + 'symbol' => $symbol, + ]); +} diff --git a/router.php b/router.php new file mode 100644 index 0000000..75ae6c2 --- /dev/null +++ b/router.php @@ -0,0 +1,12 @@ +run($force); + +echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; +exit($result['errors'] === [] ? 0 : 1); diff --git a/storage/database/.gitkeep b/storage/database/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/database/.gitkeep @@ -0,0 +1 @@ + diff --git a/storage/sessions/.gitkeep b/storage/sessions/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/sessions/.gitkeep @@ -0,0 +1 @@ + diff --git a/views/auth-layout.php b/views/auth-layout.php new file mode 100644 index 0000000..2523e91 --- /dev/null +++ b/views/auth-layout.php @@ -0,0 +1,33 @@ +get('app.name', 'Neon Ledger'); ?> + + + + + + + <?= h($title ?? 'Welcome') ?> · <?= h($appName) ?> + + + +
+
+
NL
+
+

Plan forward. Compare backward.

+

Your money, with a memory.

+

Build monthly budgets, carry unused funds forward, track bill progress, and move money out of sight without losing the paper trail.

+
+ +
+
+ +
!
+ + +
+
+ + diff --git a/views/calendar.php b/views/calendar.php new file mode 100644 index 0000000..8b4ca00 --- /dev/null +++ b/views/calendar.php @@ -0,0 +1,52 @@ +format('t'); +$startOffset = (int) $first->format('w'); +$entriesByDay = []; +foreach ($entries as $entry) { + $day = (int) date('j', strtotime($entry['event_date'])); + $entriesByDay[$day][] = $entry; +} +?> +
+

Due dates and milestones

Bills are added automatically from budget targets.

+ +
+
+
+
+
+ + +
+ + +
+ +
+ +
+ + +
+ +
+ +
+ +
+
+ + +
+ +

Personal milestone

Add calendar event

+
+ + + + +
+ +
+
diff --git a/views/comparisons.php b/views/comparisons.php new file mode 100644 index 0000000..93fb19d --- /dev/null +++ b/views/comparisons.php @@ -0,0 +1,63 @@ + $bestMonth['remaining']) { + $bestMonth = $row; + } +} +?> +
+
Σ

resources

Across all twelve months
+

expenses

0 ? number_format($totals['expenses'] / $totals['gross_resources'] * 100, 1) : '0.0' ?>% of resources
+

Moved dormant

Net dormant deposits
+

Strongest remainder

+
+ +
+
+

Month-to-month

Resources vs. expenses

+ +
+
+

Category share

+ +
No category data

Record categorized expenses to build this view.

+ + +
+ 0 ? (float) $category['spent'] / $categoryTotal * 100 : 0; ?> +
+

+
+ 0): ?>% of category limit +
+ +
+ +
+
+ +
+
+

Statistical table

monthly breakdown

+ +
+
+ + + + + + + + + + + + + + +
MonthResourcesPlannedExpensesDormantRemainder
+
+
diff --git a/views/dashboard.php b/views/dashboard.php new file mode 100644 index 0000000..29d832b --- /dev/null +++ b/views/dashboard.php @@ -0,0 +1,144 @@ += 0 ? 'green' : 'red'; +$progress = $summary['gross_resources'] > 0 ? min(100, max(0, $summary['expenses'] / $summary['gross_resources'] * 100)) : 0; +?> +
+
+
+

remaining

+
+

% of available resources spent

+
+
+ %left +
+
+
+

Gross resources

+ start + rollover +
+
+

Expenses

+ defined budget item +
+
+

Dormant balance

+ + % of this month routed away +
+
+ +
+
+
+
+

Progressional tracking

Budget targets

+ +
+ +
No targets yet

Add a bill or category target to begin progress tracking.

+ +
+ +
+ +
+
+ + +
+
+
+ paid + owing +
+
+ +
+ + +
+
+ +
+ +
+ + +
+ +
+
+

Monthly inputs

Starting point

+
+ + + + + + +
+
+ + +
+
+ + module('income')): ?> +
+

Income stream

Additional income

+ +

No additional income recorded.

+ +
+ +
· withheld+
+ +
+ +
+ + +
+

Usage stats

Planner activity

+
today
30-day visitors
page views
+
+
+
+ + +
+ +

Define the target

Add budget item

+
+ + + + + + +
+ +
+
+ + +
+ +

Beyond the starting point

Add income

+
+ + + + +
+

The configured income withholding will move automatically to the dormant account.

+ +
+
diff --git a/views/data.php b/views/data.php new file mode 100644 index 0000000..e8b83e9 --- /dev/null +++ b/views/data.php @@ -0,0 +1,46 @@ +
+ + { }

Portable planner package

Export JSON

Financial records plus non-secret configuration
+
+ + </>

Export HTML

Styled, printable monthly report
+
+ + PDF

Export PDF

Compact monthly summary and target progress
+
+
+ +
+
+

Bring a planner home

Import package

+
+ + + + +
+
+
+

Database mode

is active

Connected
+ +

SQLite remains the zero-configuration default. Use this one-time transfer to copy every planner table to an existing MySQL or MariaDB database and switch modes.

+
+ + + + + + + +
+ +
Host
Database
Charset
+

The original SQLite file remains in storage as a fallback snapshot. Switching back is done by changing config/database.json.

+ +
+
+ +
+ i +

Portable by design

JSON exports omit administrator credentials, 2FA secrets, and the Mailgun API key. HTML and PDF exports contain only the selected month’s financial summary.

+
diff --git a/views/dead-account.php b/views/dead-account.php new file mode 100644 index 0000000..34ca7d0 --- /dev/null +++ b/views/dead-account.php @@ -0,0 +1,62 @@ +
+
+

Dormant and disregarded

+

Dead account balance

+

Money here is excluded from usable monthly funds until you explicitly restore it.

+
+ + + +
+ +
+ +
+
+

Manual movement

Deposit or restore

+
+ + + + + +
+
+
+

Automatic rules

How funds arrive here

Configure →
+
+

Income withholdingget('dead.income_value', 0)) ?>get('dead.income_mode') === 'percent' ? '%' : ' fixed' ?> from each income source

+

Transaction pullget('dead.transaction_value', 0)) ?>get('dead.transaction_mode') === 'percent' ? '%' : ' fixed' ?> whenever an expense is recorded

+

RestorationsReturn to the selected month without changing historical expense records

+
+
+
+ +
+
+

Permanent paper trail

Dormant ledger

+ +
+ +
The account is empty

Automatic or manual transfers will appear here.

+ +
+ + + + + + + + + + + + + +
DateBudget monthMovementSourceAmount
= 0 ? '+' : '' ?>
+
+ +
diff --git a/views/layout.php b/views/layout.php new file mode 100644 index 0000000..5cc792a --- /dev/null +++ b/views/layout.php @@ -0,0 +1,88 @@ +get('app.name', 'Neon Ledger'); +$defaultTheme = (string) $settings->get('theme.default', 'dark'); +?> + + + + + + + <?= h($title ?? 'Planner') ?> · <?= h($appName) ?> + + + + +
+ + +
+
+ +
+

+

+
+ +
+ + + +
+ +
+ + +
+ + + +
+ + +
+
+
+ + + diff --git a/views/login.php b/views/login.php new file mode 100644 index 0000000..ff04bb1 --- /dev/null +++ b/views/login.php @@ -0,0 +1,12 @@ +
+

Administrator access

+

Welcome back

+

Sign in to continue planning.

+
+ + + + + +
+
diff --git a/views/settings.php b/views/settings.php new file mode 100644 index 0000000..d904630 --- /dev/null +++ b/views/settings.php @@ -0,0 +1,107 @@ + + +
+

Authenticator setup

Store this key now

Add the key manually in an authenticator app. It is only shown once.

+ +
Authenticator URI
+
+ + +
+ +
+ +
+
+

Identity and display

General

+
+ + + + +
+
+ +
+

Modular planner

Enabled modules

+
+ ['Income tracking', 'Starting points and additional income'], + 'bills' => ['Budget items', 'Targets, bills, and progress'], + 'calendar' => ['Bill calendar', 'Due dates and personal events'], + 'dead_account' => ['Dormant account', 'Withholding and restorations'], + 'comparisons' => ['Comparisons', 'Monthly and yearly analysis'], + 'alerts' => ['Email alerts', 'Mailgun bill reminders'], + 'import_export' => ['Data portability', 'JSON, HTML, and PDF'], + ]; + foreach ($moduleLabels as $key => [$label, $description]): + ?> + + +
+
+ +
+

Automated set-aside

Dormant account rules

+
+ + + + + +
+
+ +
+

Upcoming bills

Email alerts via Mailgun

+
+ + + + + + + + +
+

Run php scripts/send-reminders.php every minute with cron; it only sends at configured times.

+
+ +
+

Administrator protection

Security

+ +

Administrator: · account created

+
+ + +
+
+
+ +
+
+ + +
+ module('alerts')): ?> +
+ + +
+ +
+ +
+

Organize spending

Add an expense category

+
+ + + + + +
+
diff --git a/views/setup.php b/views/setup.php new file mode 100644 index 0000000..c4ed602 --- /dev/null +++ b/views/setup.php @@ -0,0 +1,14 @@ +
+

First run

+

Create the administrator

+

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

+
+ + + + + + + +
+
diff --git a/views/transactions.php b/views/transactions.php new file mode 100644 index 0000000..abeeabb --- /dev/null +++ b/views/transactions.php @@ -0,0 +1,56 @@ +
+
Available this month
+
Total spent
+
Remaining
+ +
+ +
+
+

Searchable ledger

Expense history

+ +
+ +
No expenses recorded

Record a payment to move a bill’s progress forward.

+ +
+ + + + + + + + + + + + + + +
DateDescriptionCategory / itemAmountDormant pull
+
+ + +
+
+
+ +
+ + +
+ +

Progress a target

Record expense

+
+ + + + + + +
+

A configured per-transaction amount or percentage will also be moved to the dormant account.

+ +
+
diff --git a/views/two-factor.php b/views/two-factor.php new file mode 100644 index 0000000..5e01dcf --- /dev/null +++ b/views/two-factor.php @@ -0,0 +1,11 @@ +
+

Second step

+

Authentication code

+

Enter the current six-digit code from your authenticator.

+
+ + + + +
+