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) . ' '
+ . ''
+ . 'Print / Save as PDF '
+ . '' . $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 Item Category Target Paid Owing '
+ . $itemRows . '
Expenses Date Description Category Amount '
+ . $transactionRows . '
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 = h($appName) ?>
+
+
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.
+
+
+
+ $
+
+
+
+
+ ! = h($flash['message']) ?>
+
+ = $content ?>
+
+
+
+
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;
+}
+?>
+
+
+ = h($weekday) ?>
+
+
+
+
+
+
= $day ?>
+
+
+
= $entry['event_type'] === 'bill' ? '●' : '◆' ?>
+
= h($entry['title']) ?> = h(Support::money($entry['amount'], $symbol)) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
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;
+ }
+}
+?>
+
+ Σ
= (int) $yearly['year'] ?> resources
= h(Support::money($totals['gross_resources'], $symbol)) ?> Across all twelve months
+ ↗
= (int) $yearly['year'] ?> expenses
= h(Support::money($totals['expenses'], $symbol)) ?> = $totals['gross_resources'] > 0 ? number_format($totals['expenses'] / $totals['gross_resources'] * 100, 1) : '0.0' ?>% of resources
+ ◇
Moved dormant
= h(Support::money($totals['dead_movement'], $symbol)) ?> Net dormant deposits
+ ★
Strongest remainder
= h(Support::money($bestMonth['remaining'] ?? 0, $symbol)) ?> = $bestMonth ? h(date('F', strtotime($bestMonth['month'] . '-01'))) : 'No data' ?>
+
+
+
+
+ Month-to-month
Resources vs. expenses
+
+
+
+ = h(date('F', strtotime($month . '-01'))) ?>
Category share
+
+ No category data Record categorized expenses to build this view.
+
+
+
+ 0 ? (float) $category['spent'] / $categoryTotal * 100 : 0; ?>
+
+
= h($category['category_name']) ?>= h(Support::money($category['spent'], $symbol)) ?>
+
+ 0): ?>
= number_format($category['spent'] / $category['monthly_limit'] * 100, 0) ?>% of = h(Support::money($category['monthly_limit'], $symbol)) ?> category limit
+
+
+
+
+
+
+
+
+
+
Statistical table
= (int) $yearly['year'] ?> monthly breakdown
+
⌕
+
+
+
+ Month Resources Planned Expenses Dormant Remainder
+
+
+
+ = h(date('F Y', strtotime($row['month'] . '-01'))) ?>
+ = h(Support::money($row['gross_resources'], $symbol)) ?>
+ = h(Support::money($row['planned'], $symbol)) ?>
+ = h(Support::money($row['expenses'], $symbol)) ?>
+ = h(Support::money($row['dead_movement'], $symbol)) ?>
+ = h(Support::money($row['remaining'], $symbol)) ?>
+
+
+
+
+
+
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;
+?>
+
+
+
+
= h(date('F Y', strtotime($month . '-01'))) ?> remaining
+
= h(Support::money($summary['remaining'], $symbol)) ?>
+
= number_format($progress, 1) ?>% of available resources spent
+
+
+ = number_format(100 - $progress, 0) ?>% left
+
+
+
+ ↙
Gross resources
= h(Support::money($summary['gross_resources'], $symbol)) ?>
+ = h(Support::money($summary['starting_income'], $symbol)) ?> start + = h(Support::money($summary['rollover_in'], $symbol)) ?> rollover
+
+
+ ↗
Expenses
= h(Support::money($summary['expenses'], $symbol)) ?>
+ = count($items) ?> defined budget item= count($items) === 1 ? '' : 's' ?>
+
+
+ ◇
Dormant balance
+ = h(Support::money($deadBalance, $symbol)) ?>
+ = number_format($summary['savings_rate'], 1) ?>% of this month routed away
+
+
+
+
+
+
+
+
Progressional tracking
Budget targets
+
+ Add item
+
+
+ No targets yet Add a bill or category target to begin progress tracking.
+
+
+
+
+
+
+
+ = h($item['name']) ?>
+ = h($item['category_name'] ?: 'Uncategorized') ?>= $item['due_date'] ? ' · due ' . h(date('M j', strtotime($item['due_date']))) : '' ?>
+
+
+
+ = h(Support::money($item['paid_amount'], $symbol)) ?> paid
+ = h(Support::money($item['remaining_amount'], $symbol)) ?> owing
+
+
+
= h(Support::money($item['planned_amount'], $symbol)) ?>
+
+ = csrf_field() ?>
+ ×
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monthly inputs
Starting point
+
+ = csrf_field() ?>
+ Manual starting income= h($symbol) ?>
+ Previous-month remainder= h($symbol) ?>
+ >Carry positive remainder forward
+ Monthly note= h($plan['notes']) ?>
+ Save starting point
+
+
+ = csrf_field() ?>
+ ↻ Recalculate from = h(date('F', strtotime($month . '-01 -1 month'))) ?>
+
+
+
+ module('income')): ?>
+
+ Income stream
Additional income + Add
+
+ No additional income recorded.
+
+
+
+
= h($income['label']) ?> = h(date('M j', strtotime($income['received_on']))) ?> · = h(Support::money($income['withholding_amount'], $symbol)) ?> withheld += h(Support::money($income['amount'], $symbol)) ?>
+
+
+
+
+
+
+
+ Usage stats
Planner activity
+ = (int) $visitorStats['today'] ?> today
= (int) $visitorStats['thirty_days'] ?> 30-day visitors
= (int) $visitorStats['views'] ?> page views
+
+
+
+
+
+
+ = csrf_field() ?>
+ Define the target
Add budget item ×
+
+ Add budget item
+
+
+
+
+
+ = csrf_field() ?>
+ Beyond the starting point
Add income ×
+
+ The configured income withholding will move automatically to the dormant account.
+ Record income
+
+
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 @@
+
+
+
+
+
+ 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.
+
+
+
+ •••••• Hover, then click to confirm
+
+
+ = h(Support::money($balance, $symbol)) ?>
+
+
+
+
+
+ Manual movement
Deposit or restore
+
+ = csrf_field() ?>
+ DirectionMove into dormant account Restore to this month’s usable budget
+ Amount= h($symbol) ?>
+ Reason
+ Record transfer
+
+
+
+
+
+
↙ Income withholding = h((string) $settings->get('dead.income_value', 0)) ?>= $settings->get('dead.income_mode') === 'percent' ? '%' : ' fixed' ?> from each income source
+
↗ Transaction pull = h((string) $settings->get('dead.transaction_value', 0)) ?>= $settings->get('dead.transaction_mode') === 'percent' ? '%' : ' fixed' ?> whenever an expense is recorded
+
◇ Restorations Return 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.
+
+
+
+ Date Budget month Movement Source Amount
+
+
+
+ = h(date('M j, Y g:i A', strtotime($entry['created_at']))) ?>
+ = h($entry['month']) ?>
+ = h(ucwords(str_replace('_', ' ', $entry['kind']))) ?> = h($entry['notes']) ?>
+ = h(ucfirst((string) ($entry['source_type'] ?: 'manual'))) ?>
+ = (float) $entry['amount'] >= 0 ? '+' : '' ?>= h(Support::money($entry['amount'], $symbol)) ?>
+
+
+
+
+
+
+
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) ?>
+
+
+
+
+
+
+
+
+
+
+
+
= h(date('l, F j')) ?>
+
= h($title ?? 'Planner') ?>
+
+
+
+
←
+
+ Budget month
+
+
+
→
+
+
+
+
+
+
+ = $flash['type'] === 'success' ? '✓' : '!' ?>
+ = h($flash['message']) ?>
+ ×
+
+
+
+ = $content ?>
+
+
+
+
+
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.
+
+ = csrf_field() ?>
+
+ Email address
+ Password
+ Sign in
+
+
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.
+
= h($newSecret) ?>
+
Authenticator URI = h($totpUri) ?>
+
+
+
+
+ = csrf_field() ?>
+
+
+ Configuration
+ General Modules Dormant rules Alerts & Mailgun Security
+
+
+
+ Identity and display
General
+
+ Planner name
+ Currency symbol
+ Default theme>Dark >Light
+ Timezone
+
+
+
+
+ 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]):
+ ?>
+ = h($label) ?> = h($description) ?> >
+
+
+
+
+
+ Automated set-aside
Dormant account rules
+
+ Income rule>Percentage >Direct amount
+ Income value
+ Per-transaction rule>Percentage >Direct amount
+ Per-transaction value
+ >Conceal dormant balance Require a hover and confirmation click to reveal it.
+
+
+
+
+ Upcoming bills
Email alerts via Mailgun = $mailgunConfigured ? 'Configured' : 'Needs setup' ?>
+
+ Alert recipient
+ Alert windows (days)
+ Reminder times
+ Mailgun domain
+ Mailgun region>United States >European Union
+ From address
+ Mailgun API key
+ Clear the stored API key
+
+ Run php scripts/send-reminders.php every minute with cron; it only sends at configured times.
+
+
+
+ Administrator protection
Security
+ >Require two-factor authentication Uses any standard TOTP authenticator app.
+ Administrator: = h($user['email']) ?> · account created = h(date('M j, Y', strtotime($user['created_at']))) ?>
+
+
+
Save all settings
+
+
+
+
+
+
+ = csrf_field() ?>
+ Generate new 2FA key
+
+ module('alerts')): ?>
+
+ = csrf_field() ?>
+ Run reminders now
+
+
+
+
+
+ Organize spending
Add an expense category
+
+ = csrf_field() ?>
+ Name
+ Color
+ Optional monthly limit
+ Add 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 @@
+
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 = h(Support::money($summary['gross_resources'], $symbol)) ?>
+ Total spent = h(Support::money($summary['expenses'], $symbol)) ?>
+ Remaining = h(Support::money($summary['remaining'], $symbol)) ?>
+ + Record expense
+
+
+
+
+
Searchable ledger
Expense history
+
⌕
+
+
+ No expenses recorded Record a payment to move a bill’s progress forward.
+
+
+
+ Date Description Category / item Amount Dormant pull
+
+
+
+ = h(date('M j, Y', strtotime($transaction['transacted_on']))) ?>
+ = h($transaction['merchant']) ?> = h($transaction['notes']) ?>
+ = h($transaction['category_name'] ?: 'Uncategorized') ?>= h($transaction['item_name']) ?>
+ = h(Support::money($transaction['amount'], $symbol)) ?>
+ = h(Support::money($transaction['dead_pull_amount'], $symbol)) ?>
+
+
+ = csrf_field() ?>
+ ×
+
+
+
+
+
+
+
+
+
+
+
+
+ = csrf_field() ?>
+ Progress a target
Record expense ×
+
+ A configured per-transaction amount or percentage will also be moved to the dormant account.
+ Record expense
+
+
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.
+
+ = csrf_field() ?>
+
+ Six-digit code
+ Verify and continue
+
+