This commit is contained in:
Ty Clifford
2026-06-14 15:25:31 -04:00
parent 2f67ae718f
commit b7eaa81501
34 changed files with 3814 additions and 2 deletions
+6
View File
@@ -0,0 +1,6 @@
/storage/database/*
!/storage/database/.gitkeep
/storage/sessions/*
!/storage/sessions/.gitkeep
/.DS_Store
*.log
+58 -2
View File
@@ -1,3 +1,59 @@
# budget
# Neon Ledger
A financial planner.
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.
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Budget;
use PDO;
final class Auth
{
public function __construct(private PDO $pdo, private Settings $settings)
{
}
public function needsSetup(): bool
{
return (int) $this->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);
}
}
+630
View File
@@ -0,0 +1,630 @@
<?php
declare(strict_types=1);
namespace Budget;
use DateTimeImmutable;
use PDO;
use RuntimeException;
final class BudgetService
{
public function __construct(private PDO $pdo, private Settings $settings)
{
}
public function ensureMonth(string $month): array
{
$month = Support::month($month);
$plan = $this->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'],
]);
}
}
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Budget;
use PDO;
use PDOException;
use RuntimeException;
final class Database
{
private PDO $pdo;
private array $config;
private string $configPath;
public function __construct(string $configPath, string $rootPath)
{
$this->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;
}
}
+221
View File
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Budget;
use PDO;
use RuntimeException;
final class ExportService
{
private const TABLES = [
'monthly_plans', 'income_entries', 'categories', 'budget_items',
'transactions', 'dead_account_entries', 'calendar_events',
];
public function __construct(private PDO $pdo, private Settings $settings, private BudgetService $budget)
{
}
public function package(): string
{
$data = [];
foreach (self::TABLES as $table) {
$data[$table] = $this->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(
'<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>',
$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(
'<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>',
$escape($transaction['transacted_on']),
$escape($transaction['merchant']),
$escape($transaction['category_name'] ?: 'Uncategorized'),
$money($transaction['amount'])
);
}
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Budget report ' . $escape($month) . '</title>'
. '<style>body{font:15px/1.5 system-ui;color:#182033;max-width:1000px;margin:40px auto;padding:0 24px}h1{color:#5b36d9}'
. '.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.stat{border:1px solid #ddd;border-radius:10px;padding:14px}'
. 'table{width:100%;border-collapse:collapse;margin:20px 0}th,td{text-align:left;padding:9px;border-bottom:1px solid #ddd}th{color:#5b36d9}'
. '@media print{body{margin:0}.no-print{display:none}}</style></head><body>'
. '<button class="no-print" onclick="print()">Print / Save as PDF</button>'
. '<h1>' . $escape($this->settings->get('app.name', 'Neon Ledger')) . ' &mdash; ' . $escape(date('F Y', strtotime($month . '-01'))) . '</h1>'
. '<div class="stats"><div class="stat"><small>Resources</small><strong>' . $money($summary['gross_resources']) . '</strong></div>'
. '<div class="stat"><small>Expenses</small><strong>' . $money($summary['expenses']) . '</strong></div>'
. '<div class="stat"><small>Dormant movement</small><strong>' . $money($summary['dead_movement']) . '</strong></div>'
. '<div class="stat"><small>Remaining</small><strong>' . $money($summary['remaining']) . '</strong></div></div>'
. '<h2>Budget progress</h2><table><thead><tr><th>Item</th><th>Category</th><th>Target</th><th>Paid</th><th>Owing</th></tr></thead><tbody>'
. $itemRows . '</tbody></table><h2>Expenses</h2><table><thead><tr><th>Date</th><th>Description</th><th>Category</th><th>Amount</th></tr></thead><tbody>'
. $transactionRows . '</tbody></table><p>Exported ' . $escape(date(DATE_RFC2822)) . '</p></body></html>';
}
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;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Budget;
use RuntimeException;
final class Mailgun
{
public function __construct(private Settings $settings)
{
}
public function configured(): bool
{
return $this->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];
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Budget;
use DateTimeImmutable;
use PDO;
final class ReminderService
{
public function __construct(private PDO $pdo, private Settings $settings, private Mailgun $mailgun)
{
}
public function run(bool $force = false): array
{
if (!(bool) $this->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(
'<h1>%s</h1><p>Your %s budget item is due on <strong>%s</strong>.</p><p>Planned amount: <strong>%s%.2f</strong></p>',
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,
]);
}
}
+276
View File
@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace Budget;
final class Schema
{
/** @return list<string> */
public static function statements(string $driver): array
{
return $driver === 'mysql' ? self::mysql() : self::sqlite();
}
/** @return list<string> */
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<string> */
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',
];
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Budget;
use PDO;
final class Settings
{
private const DEFAULTS = [
'app.name' => '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 <budget@example.com>',
'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<string, mixed> */
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);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Budget;
use PDO;
final class Support
{
public static function month(?string $month): string
{
$month = $month ?: date('Y-m');
if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
return date('Y-m');
}
return $month;
}
public static function money(float|int|string $amount, string $symbol = '$'): string
{
$value = (float) $amount;
return ($value < 0 ? '-' : '') . $symbol . number_format(abs($value), 2);
}
public static function csrf(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return (string) $_SESSION['csrf_token'];
}
public static function verifyCsrf(?string $token): void
{
if (!is_string($token) || !hash_equals(self::csrf(), $token)) {
throw new \RuntimeException('The form expired. Please try again.');
}
}
public static function flash(string $type, string $message): void
{
$_SESSION['flash'][] = ['type' => $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]);
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Budget;
final class Totp
{
private const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
public static function generateSecret(int $length = 24): string
{
$secret = '';
for ($index = 0; $index < $length; $index++) {
$secret .= self::ALPHABET[random_int(0, 31)];
}
return $secret;
}
public static function verify(string $secret, string $code, int $window = 1): bool
{
$code = preg_replace('/\D/', '', $code) ?? '';
if (strlen($code) !== 6) {
return false;
}
$counter = (int) floor(time() / 30);
for ($offset = -$window; $offset <= $window; $offset++) {
if (hash_equals(self::code($secret, $counter + $offset), $code)) {
return true;
}
}
return false;
}
public static function uri(string $secret, string $email, string $issuer): string
{
$label = rawurlencode($issuer . ':' . $email);
return sprintf(
'otpauth://totp/%s?secret=%s&issuer=%s&digits=6&period=30',
$label,
rawurlencode($secret),
rawurlencode($issuer)
);
}
private static function code(string $secret, int $counter): string
{
$key = self::decodeBase32($secret);
$binaryCounter = pack('N2', 0, $counter);
$hash = hash_hmac('sha1', $binaryCounter, $key, true);
$offset = ord($hash[19]) & 0x0f;
$value = (
((ord($hash[$offset]) & 0x7f) << 24) |
((ord($hash[$offset + 1]) & 0xff) << 16) |
((ord($hash[$offset + 2]) & 0xff) << 8) |
(ord($hash[$offset + 3]) & 0xff)
) % 1000000;
return str_pad((string) $value, 6, '0', STR_PAD_LEFT);
}
private static function decodeBase32(string $secret): string
{
$secret = strtoupper(preg_replace('/[^A-Z2-7]/i', '', $secret) ?? '');
$bits = '';
foreach (str_split($secret) as $character) {
$position = strpos(self::ALPHABET, $character);
$bits .= str_pad(decbin($position === false ? 0 : $position), 5, '0', STR_PAD_LEFT);
}
$output = '';
foreach (str_split($bits, 8) as $byte) {
if (strlen($byte) === 8) {
$output .= chr(bindec($byte));
}
}
return $output;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Budget\Auth;
use Budget\BudgetService;
use Budget\Database;
use Budget\Settings;
const APP_ROOT = __DIR__ . '/..';
spl_autoload_register(static function (string $class): void {
$prefix = 'Budget\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$path = __DIR__ . '/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($path)) {
require $path;
}
});
if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) {
session_name('neon_ledger_session');
session_set_cookie_params([
'httponly' => 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);
+12
View File
@@ -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"
}
}
+8
View File
@@ -0,0 +1,8 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
<FilesMatch "\.(sqlite|json)$">
Require all denied
</FilesMatch>
+518
View File
@@ -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; }
}
+181
View File
@@ -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);
});
})();
+461
View File
@@ -0,0 +1,461 @@
<?php
declare(strict_types=1);
use Budget\ExportService;
use Budget\Mailgun;
use Budget\ReminderService;
use Budget\Support;
use Budget\Totp;
require __DIR__ . '/../app/bootstrap.php';
function h(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function url(string $route, array $parameters = []): string
{
return '?' . http_build_query(['route' => $route] + $parameters);
}
function csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="' . h(Support::csrf()) . '">';
}
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,
]);
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$file = __DIR__ . '/public' . $path;
if ($path !== '/' && is_file($file)) {
return false;
}
require __DIR__ . '/public/index.php';
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
use Budget\Mailgun;
use Budget\ReminderService;
require __DIR__ . '/../app/bootstrap.php';
$force = in_array('--force', $argv, true);
$service = new ReminderService($pdo, $settings, new Mailgun($settings));
$result = $service->run($force);
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit($result['errors'] === [] ? 0 : 1);
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+33
View File
@@ -0,0 +1,33 @@
<?php $appName = (string) $settings->get('app.name', 'Neon Ledger'); ?>
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title><?= h($title ?? 'Welcome') ?> · <?= h($appName) ?></title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body class="auth-body">
<main class="auth-shell">
<section class="auth-story">
<div class="brand auth-brand"><span class="brand-mark">NL</span><strong><?= h($appName) ?></strong></div>
<div>
<p class="eyebrow">Plan forward. Compare backward.</p>
<h1>Your money, with a memory.</h1>
<p>Build monthly budgets, carry unused funds forward, track bill progress, and move money out of sight without losing the paper trail.</p>
</div>
<div class="auth-orbit" aria-hidden="true">
<span class="orbit-one"></span><span class="orbit-two"></span><span class="orbit-three"></span>
<strong>$</strong>
</div>
</section>
<section class="auth-panel">
<?php foreach ($flashes as $flash): ?>
<div class="flash <?= h($flash['type']) ?>"><span>!</span><?= h($flash['message']) ?></div>
<?php endforeach; ?>
<?= $content ?>
</section>
</main>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<?php
$first = new DateTimeImmutable($month . '-01');
$daysInMonth = (int) $first->format('t');
$startOffset = (int) $first->format('w');
$entriesByDay = [];
foreach ($entries as $entry) {
$day = (int) date('j', strtotime($entry['event_date']));
$entriesByDay[$day][] = $entry;
}
?>
<section class="calendar-toolbar">
<div><p class="eyebrow">Due dates and milestones</p><p class="muted">Bills are added automatically from budget targets.</p></div>
<button class="button primary" type="button" data-dialog-open="event-dialog">+ Add event</button>
</section>
<article class="card calendar-card">
<div class="calendar-weekdays"><?php foreach (['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as $weekday): ?><span><?= h($weekday) ?></span><?php endforeach; ?></div>
<div class="calendar-grid">
<?php for ($blank = 0; $blank < $startOffset; $blank++): ?><div class="calendar-day blank"></div><?php endfor; ?>
<?php for ($day = 1; $day <= $daysInMonth; $day++): ?>
<?php $isToday = date('Y-m-d') === $month . '-' . str_pad((string) $day, 2, '0', STR_PAD_LEFT); ?>
<div class="calendar-day <?= $isToday ? 'today' : '' ?>">
<span class="day-number"><?= $day ?></span>
<?php foreach ($entriesByDay[$day] ?? [] as $entry): ?>
<div class="calendar-event <?= h($entry['event_type']) ?>">
<span><?= $entry['event_type'] === 'bill' ? '●' : '◆' ?></span>
<div><strong><?= h($entry['title']) ?></strong><?php if ($entry['amount'] !== null): ?><small><?= h(Support::money($entry['amount'], $symbol)) ?></small><?php endif; ?></div>
<?php if ($entry['event_type'] !== 'bill'): ?>
<form method="post" data-confirm="Remove this calendar event?">
<?= csrf_field() ?><input type="hidden" name="action" value="event.delete"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $entry['id'] ?>">
<button type="submit" aria-label="Delete event">×</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endfor; ?>
</div>
</article>
<dialog id="event-dialog">
<form method="post" class="dialog-card">
<?= csrf_field() ?><input type="hidden" name="action" value="event.add"><input type="hidden" name="month" value="<?= h($month) ?>">
<div class="card-heading"><div><p class="eyebrow">Personal milestone</p><h2>Add calendar event</h2></div><button class="icon-button" type="button" data-dialog-close>×</button></div>
<div class="form-grid">
<label class="span-2">Title<input type="text" name="title" required placeholder="Payday, renewal, deadline…"></label>
<label>Date<input type="date" name="event_date" value="<?= h($month . '-01') ?>" required></label>
<label>Type<select name="event_type"><option value="note">Note</option><option value="payday">Payday</option><option value="deadline">Deadline</option></select></label>
<label class="span-2">Notes<textarea name="notes" rows="2"></textarea></label>
</div>
<button class="button primary full" type="submit">Add to calendar</button>
</form>
</dialog>
+63
View File
@@ -0,0 +1,63 @@
<?php
$totals = $yearly['totals'];
$bestMonth = null;
foreach ($yearly['months'] as $row) {
if ($bestMonth === null || $row['remaining'] > $bestMonth['remaining']) {
$bestMonth = $row;
}
}
?>
<section class="hero-grid comparison-metrics">
<article class="metric-card card"><div class="metric-icon blue">Σ</div><p><?= (int) $yearly['year'] ?> resources</p><strong><?= h(Support::money($totals['gross_resources'], $symbol)) ?></strong><small>Across all twelve months</small></article>
<article class="metric-card card"><div class="metric-icon pink">↗</div><p><?= (int) $yearly['year'] ?> expenses</p><strong><?= h(Support::money($totals['expenses'], $symbol)) ?></strong><small><?= $totals['gross_resources'] > 0 ? number_format($totals['expenses'] / $totals['gross_resources'] * 100, 1) : '0.0' ?>% of resources</small></article>
<article class="metric-card card"><div class="metric-icon orange">◇</div><p>Moved dormant</p><strong><?= h(Support::money($totals['dead_movement'], $symbol)) ?></strong><small>Net dormant deposits</small></article>
<article class="metric-card card"><div class="metric-icon green">★</div><p>Strongest remainder</p><strong><?= h(Support::money($bestMonth['remaining'] ?? 0, $symbol)) ?></strong><small><?= $bestMonth ? h(date('F', strtotime($bestMonth['month'] . '-01'))) : 'No data' ?></small></article>
</section>
<section class="content-grid comparison-grid">
<article class="card span-2">
<div class="card-heading"><div><p class="eyebrow">Month-to-month</p><h2>Resources vs. expenses</h2></div></div>
<canvas class="trend-chart large" data-trend-chart='<?= h(json_encode($comparison)) ?>' data-symbol="<?= h($symbol) ?>" height="280"></canvas>
</article>
<article class="card">
<div class="card-heading"><div><p class="eyebrow"><?= h(date('F', strtotime($month . '-01'))) ?></p><h2>Category share</h2></div></div>
<?php if ($categoryStats === []): ?>
<div class="empty-state compact"><strong>No category data</strong><p>Record categorized expenses to build this view.</p></div>
<?php else: ?>
<?php $categoryTotal = array_sum(array_column($categoryStats, 'spent')); ?>
<div class="category-bars">
<?php foreach ($categoryStats as $category): $width = $categoryTotal > 0 ? (float) $category['spent'] / $categoryTotal * 100 : 0; ?>
<div>
<p><span><i style="--dot:<?= h($category['color']) ?>"></i><?= h($category['category_name']) ?></span><strong><?= h(Support::money($category['spent'], $symbol)) ?></strong></p>
<div class="progress-track"><span style="width:<?= h((string) $width) ?>%;--bar:<?= h($category['color']) ?>"></span></div>
<?php if ((float) $category['monthly_limit'] > 0): ?><small><?= number_format($category['spent'] / $category['monthly_limit'] * 100, 0) ?>% of <?= h(Support::money($category['monthly_limit'], $symbol)) ?> category limit</small><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
</section>
<article class="card">
<div class="card-heading">
<div><p class="eyebrow">Statistical table</p><h2><?= (int) $yearly['year'] ?> monthly breakdown</h2></div>
<label class="search-box"><span>⌕</span><input type="search" placeholder="Find a month…" data-table-search="year-table"></label>
</div>
<div class="table-wrap">
<table id="year-table">
<thead><tr><th>Month</th><th>Resources</th><th>Planned</th><th>Expenses</th><th>Dormant</th><th>Remainder</th></tr></thead>
<tbody>
<?php foreach ($yearly['months'] as $row): ?>
<tr>
<td><a class="text-link" href="<?= h(url('comparisons', ['month' => $row['month']])) ?>"><?= h(date('F Y', strtotime($row['month'] . '-01'))) ?></a></td>
<td><?= h(Support::money($row['gross_resources'], $symbol)) ?></td>
<td><?= h(Support::money($row['planned'], $symbol)) ?></td>
<td><?= h(Support::money($row['expenses'], $symbol)) ?></td>
<td><?= h(Support::money($row['dead_movement'], $symbol)) ?></td>
<td class="<?= $row['remaining'] >= 0 ? 'green' : 'red' ?>"><strong><?= h(Support::money($row['remaining'], $symbol)) ?></strong></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</article>
+144
View File
@@ -0,0 +1,144 @@
<?php
$remainingTone = $summary['remaining'] >= 0 ? 'green' : 'red';
$progress = $summary['gross_resources'] > 0 ? min(100, max(0, $summary['expenses'] / $summary['gross_resources'] * 100)) : 0;
?>
<section class="hero-grid">
<article class="hero-balance card glow-purple">
<div>
<p class="eyebrow"><?= h(date('F Y', strtotime($month . '-01'))) ?> remaining</p>
<div class="hero-amount <?= h($remainingTone) ?>"><?= h(Support::money($summary['remaining'], $symbol)) ?></div>
<p class="muted"><?= number_format($progress, 1) ?>% of available resources spent</p>
</div>
<div class="balance-ring" style="--progress:<?= h((string) $progress) ?>">
<span><?= number_format(100 - $progress, 0) ?>%</span><small>left</small>
</div>
</article>
<article class="metric-card card">
<div class="metric-icon blue">↙</div><p>Gross resources</p><strong><?= h(Support::money($summary['gross_resources'], $symbol)) ?></strong>
<small><?= h(Support::money($summary['starting_income'], $symbol)) ?> start + <?= h(Support::money($summary['rollover_in'], $symbol)) ?> rollover</small>
</article>
<article class="metric-card card">
<div class="metric-icon pink">↗</div><p>Expenses</p><strong><?= h(Support::money($summary['expenses'], $symbol)) ?></strong>
<small><?= count($items) ?> defined budget item<?= count($items) === 1 ? '' : 's' ?></small>
</article>
<article class="metric-card card">
<div class="metric-icon orange">◇</div><p>Dormant balance</p>
<strong><?= h(Support::money($deadBalance, $symbol)) ?></strong>
<small><?= number_format($summary['savings_rate'], 1) ?>% of this month routed away</small>
</article>
</section>
<section class="content-grid dashboard-grid">
<div class="span-2 stack">
<article class="card">
<div class="card-heading">
<div><p class="eyebrow">Progressional tracking</p><h2>Budget targets</h2></div>
<button class="button small" type="button" data-dialog-open="item-dialog">+ Add item</button>
</div>
<?php if ($items === []): ?>
<div class="empty-state"><strong>No targets yet</strong><p>Add a bill or category target to begin progress tracking.</p></div>
<?php else: ?>
<div class="budget-list">
<?php foreach ($items as $item): ?>
<div class="budget-row">
<span class="category-dot" style="--dot:<?= h($item['category_color'] ?: '#718096') ?>"></span>
<div class="budget-main">
<div class="budget-title">
<strong><?= h($item['name']) ?></strong>
<span><?= h($item['category_name'] ?: 'Uncategorized') ?><?= $item['due_date'] ? ' · due ' . h(date('M j', strtotime($item['due_date']))) : '' ?></span>
</div>
<div class="progress-track"><span style="width:<?= h((string) $item['progress']) ?>%;--bar:<?= h($item['category_color'] ?: '#7c5cff') ?>"></span></div>
<div class="budget-meta">
<span><?= h(Support::money($item['paid_amount'], $symbol)) ?> paid</span>
<span><?= h(Support::money($item['remaining_amount'], $symbol)) ?> owing</span>
</div>
</div>
<strong class="target"><?= h(Support::money($item['planned_amount'], $symbol)) ?></strong>
<form method="post" data-confirm="Remove this budget item? Existing expenses will remain.">
<?= csrf_field() ?><input type="hidden" name="action" value="item.delete"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $item['id'] ?>">
<button class="icon-button danger" title="Delete item" type="submit">×</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
<article class="card">
<div class="card-heading">
<div><p class="eyebrow">Comparative view</p><h2>Six-month movement</h2></div>
<a class="text-link" href="<?= h(url('comparisons', ['month' => $month])) ?>">Full analysis →</a>
</div>
<canvas class="trend-chart" data-trend-chart='<?= h(json_encode($comparison)) ?>' data-symbol="<?= h($symbol) ?>" height="220"></canvas>
</article>
</div>
<div class="stack">
<article class="card accent-card">
<div class="card-heading"><div><p class="eyebrow">Monthly inputs</p><h2>Starting point</h2></div></div>
<form method="post" class="stack-form compact">
<?= csrf_field() ?><input type="hidden" name="action" value="plan.save"><input type="hidden" name="month" value="<?= h($month) ?>">
<label>Manual starting income<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="starting_income" min="0" step="0.01" value="<?= h((string) $plan['starting_income']) ?>"></div></label>
<label>Previous-month remainder<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="rollover_in" min="0" step="0.01" value="<?= h((string) $plan['rollover_in']) ?>"></div></label>
<label class="check-row slim"><input type="checkbox" name="rolling_enabled" value="1" <?= $plan['rolling_enabled'] ? 'checked' : '' ?>><span>Carry positive remainder forward</span></label>
<label>Monthly note<textarea name="notes" rows="2" placeholder="What matters this month?"><?= h($plan['notes']) ?></textarea></label>
<button class="button primary" type="submit">Save starting point</button>
</form>
<form method="post" class="inline-form top-gap">
<?= csrf_field() ?><input type="hidden" name="action" value="plan.refresh_rollover"><input type="hidden" name="month" value="<?= h($month) ?>">
<button class="text-link" type="submit">↻ Recalculate from <?= h(date('F', strtotime($month . '-01 -1 month'))) ?></button>
</form>
</article>
<?php if ($settings->module('income')): ?>
<article class="card">
<div class="card-heading"><div><p class="eyebrow">Income stream</p><h2>Additional income</h2></div><button class="button small secondary" type="button" data-dialog-open="income-dialog">+ Add</button></div>
<?php if ($incomes === []): ?>
<p class="muted">No additional income recorded.</p>
<?php else: ?>
<div class="mini-list">
<?php foreach (array_slice($incomes, 0, 5) as $income): ?>
<div><span><strong><?= h($income['label']) ?></strong><small><?= h(date('M j', strtotime($income['received_on']))) ?> · <?= h(Support::money($income['withholding_amount'], $symbol)) ?> withheld</small></span><strong class="green">+<?= h(Support::money($income['amount'], $symbol)) ?></strong></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
<?php endif; ?>
<article class="card stats-card">
<div class="card-heading"><div><p class="eyebrow">Usage stats</p><h2>Planner activity</h2></div></div>
<div class="stat-trio"><div><strong><?= (int) $visitorStats['today'] ?></strong><span>today</span></div><div><strong><?= (int) $visitorStats['thirty_days'] ?></strong><span>30-day visitors</span></div><div><strong><?= (int) $visitorStats['views'] ?></strong><span>page views</span></div></div>
</article>
</div>
</section>
<dialog id="item-dialog">
<form method="post" class="dialog-card">
<?= csrf_field() ?><input type="hidden" name="action" value="item.add"><input type="hidden" name="month" value="<?= h($month) ?>">
<div class="card-heading"><div><p class="eyebrow">Define the target</p><h2>Add budget item</h2></div><button class="icon-button" type="button" data-dialog-close>×</button></div>
<div class="form-grid">
<label class="span-2">Name<input type="text" name="name" required placeholder="Internet, rent, groceries…"></label>
<label>Target amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="planned_amount" min="0" step="0.01" required></div></label>
<label>Category<select name="category_id"><option value="">Uncategorized</option><?php foreach ($categories as $category): ?><option value="<?= (int) $category['id'] ?>"><?= h($category['name']) ?></option><?php endforeach; ?></select></label>
<label>Due date<input type="date" name="due_date" value="<?= h($month . '-01') ?>"></label>
<label>Repeat<select name="recurrence"><option value="none">One time</option><option value="monthly">Monthly</option><option value="yearly">Yearly</option></select></label>
<label class="span-2">Notes<textarea name="notes" rows="2"></textarea></label>
</div>
<button class="button primary full" type="submit">Add budget item</button>
</form>
</dialog>
<dialog id="income-dialog">
<form method="post" class="dialog-card">
<?= csrf_field() ?><input type="hidden" name="action" value="income.add"><input type="hidden" name="month" value="<?= h($month) ?>">
<div class="card-heading"><div><p class="eyebrow">Beyond the starting point</p><h2>Add income</h2></div><button class="icon-button" type="button" data-dialog-close>×</button></div>
<div class="form-grid">
<label class="span-2">Label<input type="text" name="label" required placeholder="Paycheck, freelance, refund…"></label>
<label>Amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="amount" min="0.01" step="0.01" required></div></label>
<label>Received<input type="date" name="received_on" value="<?= h(date('Y-m-d')) ?>" required></label>
<label class="span-2">Notes<textarea name="notes" rows="2"></textarea></label>
</div>
<p class="form-note">The configured income withholding will move automatically to the dormant account.</p>
<button class="button primary full" type="submit">Record income</button>
</form>
</dialog>
+46
View File
@@ -0,0 +1,46 @@
<section class="hero-grid data-actions">
<a class="card export-card purple" href="<?= h(url('export-json')) ?>">
<span class="export-icon">{ }</span><div><p class="eyebrow">Portable planner package</p><h2>Export JSON</h2><small>Financial records plus non-secret configuration</small></div><b></b>
</a>
<a class="card export-card blue" href="<?= h(url('export-html', ['month' => $month])) ?>">
<span class="export-icon">&lt;/&gt;</span><div><p class="eyebrow"><?= h($month) ?></p><h2>Export HTML</h2><small>Styled, printable monthly report</small></div><b>↓</b>
</a>
<a class="card export-card pink" href="<?= h(url('export-pdf', ['month' => $month])) ?>">
<span class="export-icon">PDF</span><div><p class="eyebrow"><?= h($month) ?></p><h2>Export PDF</h2><small>Compact monthly summary and target progress</small></div><b>↓</b>
</a>
</section>
<section class="content-grid data-grid">
<article class="card">
<div class="card-heading"><div><p class="eyebrow">Bring a planner home</p><h2>Import package</h2></div></div>
<form method="post" enctype="multipart/form-data" class="stack-form" data-confirm="Import this planner package? Replacing data cannot be undone without a backup.">
<?= csrf_field() ?><input type="hidden" name="action" value="data.import">
<label class="file-drop">Planner JSON file<input type="file" name="planner_file" accept="application/json,.json" required><span>Choose a <code>neon-ledger/budget-planner</code> package</span></label>
<label class="check-row"><input type="checkbox" name="replace_existing" value="1"><span><strong>Replace existing financial data</strong><small>Otherwise matching record IDs are skipped and new records are merged.</small></span></label>
<button class="button primary" type="submit">Import planner data</button>
</form>
</article>
<article class="card">
<div class="card-heading"><div><p class="eyebrow">Database mode</p><h2><?= h(strtoupper($databaseDriver)) ?> is active</h2></div><span class="status-badge success">Connected</span></div>
<?php if ($databaseDriver === 'sqlite'): ?>
<p class="muted">SQLite remains the zero-configuration default. Use this one-time transfer to copy every planner table to an existing MySQL or MariaDB database and switch modes.</p>
<form method="post" class="form-grid" data-confirm="Copy all SQLite records and switch this planner to MySQL/MariaDB?">
<?= csrf_field() ?><input type="hidden" name="action" value="database.migrate">
<label>Host<input type="text" name="host" value="127.0.0.1" required></label>
<label>Port<input type="number" name="port" value="3306" required></label>
<label>Database<input type="text" name="database" required placeholder="budget_planner"></label>
<label>Username<input type="text" name="username" required autocomplete="username"></label>
<label class="span-2">Password<input type="password" name="password" autocomplete="current-password"></label>
<button class="button primary span-2" type="submit">Copy data and switch database</button>
</form>
<?php else: ?>
<div class="database-facts"><div><span>Host</span><strong><?= h($databaseConfig['mysql']['host'] ?? '') ?></strong></div><div><span>Database</span><strong><?= h($databaseConfig['mysql']['database'] ?? '') ?></strong></div><div><span>Charset</span><strong><?= h($databaseConfig['mysql']['charset'] ?? 'utf8mb4') ?></strong></div></div>
<p class="form-note">The original SQLite file remains in storage as a fallback snapshot. Switching back is done by changing <code>config/database.json</code>.</p>
<?php endif; ?>
</article>
</section>
<article class="card portability-note">
<span class="metric-icon orange">i</span>
<div><h2>Portable by design</h2><p>JSON exports omit administrator credentials, 2FA secrets, and the Mailgun API key. HTML and PDF exports contain only the selected months financial summary.</p></div>
</article>
+62
View File
@@ -0,0 +1,62 @@
<section class="dead-hero card glow-blue">
<div>
<p class="eyebrow">Dormant and disregarded</p>
<h2>Dead account balance</h2>
<p>Money here is excluded from usable monthly funds until you explicitly restore it.</p>
</div>
<?php if ($hideBalance): ?>
<button class="concealed-balance" type="button" data-concealed-balance data-value="<?= h(Support::money($balance, $symbol)) ?>">
<span class="masked">••••••</span><span class="revealed"></span><small>Hover, then click to confirm</small>
</button>
<?php else: ?>
<div class="visible-balance"><?= h(Support::money($balance, $symbol)) ?></div>
<?php endif; ?>
</section>
<section class="content-grid dead-grid">
<article class="card">
<div class="card-heading"><div><p class="eyebrow">Manual movement</p><h2>Deposit or restore</h2></div></div>
<form method="post" class="stack-form">
<?= csrf_field() ?><input type="hidden" name="action" value="dead.move"><input type="hidden" name="month" value="<?= h($month) ?>">
<label>Direction<select name="direction"><option value="deposit">Move into dormant account</option><option value="restore">Restore to this months usable budget</option></select></label>
<label>Amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="amount" min="0.01" step="0.01" required></div></label>
<label>Reason<textarea name="notes" rows="3" placeholder="Emergency reserve, return to groceries…"></textarea></label>
<button class="button primary" type="submit">Record transfer</button>
</form>
</article>
<article class="card">
<div class="card-heading"><div><p class="eyebrow">Automatic rules</p><h2>How funds arrive here</h2></div><a class="text-link" href="<?= h(url('settings')) ?>">Configure →</a></div>
<div class="rule-list">
<div><span class="metric-icon blue">↙</span><p><strong>Income withholding</strong><small><?= h((string) $settings->get('dead.income_value', 0)) ?><?= $settings->get('dead.income_mode') === 'percent' ? '%' : ' fixed' ?> from each income source</small></p></div>
<div><span class="metric-icon pink">↗</span><p><strong>Transaction pull</strong><small><?= h((string) $settings->get('dead.transaction_value', 0)) ?><?= $settings->get('dead.transaction_mode') === 'percent' ? '%' : ' fixed' ?> whenever an expense is recorded</small></p></div>
<div><span class="metric-icon orange">◇</span><p><strong>Restorations</strong><small>Return to the selected month without changing historical expense records</small></p></div>
</div>
</article>
</section>
<article class="card">
<div class="card-heading">
<div><p class="eyebrow">Permanent paper trail</p><h2>Dormant ledger</h2></div>
<label class="search-box"><span>⌕</span><input type="search" placeholder="Search movements…" data-table-search="dead-table"></label>
</div>
<?php if ($entries === []): ?>
<div class="empty-state"><strong>The account is empty</strong><p>Automatic or manual transfers will appear here.</p></div>
<?php else: ?>
<div class="table-wrap">
<table id="dead-table">
<thead><tr><th>Date</th><th>Budget month</th><th>Movement</th><th>Source</th><th>Amount</th></tr></thead>
<tbody>
<?php foreach ($entries as $entry): ?>
<tr>
<td><?= h(date('M j, Y g:i A', strtotime($entry['created_at']))) ?></td>
<td><a class="text-link" href="<?= h(url('dead-account', ['month' => $entry['month']])) ?>"><?= h($entry['month']) ?></a></td>
<td><strong><?= h(ucwords(str_replace('_', ' ', $entry['kind']))) ?></strong><?php if ($entry['notes']): ?><small><?= h($entry['notes']) ?></small><?php endif; ?></td>
<td><?= h(ucfirst((string) ($entry['source_type'] ?: 'manual'))) ?></td>
<td class="<?= (float) $entry['amount'] >= 0 ? 'green' : 'orange' ?>"><strong><?= (float) $entry['amount'] >= 0 ? '+' : '' ?><?= h(Support::money($entry['amount'], $symbol)) ?></strong></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</article>
+88
View File
@@ -0,0 +1,88 @@
<?php
$currentRoute = $route ?? (string) ($_GET['route'] ?? 'dashboard');
$currentMonth = $month ?? date('Y-m');
$previousMonth = date('Y-m', strtotime($currentMonth . '-01 -1 month'));
$nextMonth = date('Y-m', strtotime($currentMonth . '-01 +1 month'));
$appName = (string) $settings->get('app.name', 'Neon Ledger');
$defaultTheme = (string) $settings->get('theme.default', 'dark');
?>
<!doctype html>
<html lang="en" data-theme="<?= h($defaultTheme) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title><?= h($title ?? 'Planner') ?> · <?= h($appName) ?></title>
<link rel="stylesheet" href="/assets/app.css">
<script>document.documentElement.dataset.theme=localStorage.getItem('budget-theme')||<?= json_encode($defaultTheme) ?>;</script>
</head>
<body>
<div class="app-shell">
<aside class="sidebar" id="sidebar">
<a class="brand" href="<?= h(url('dashboard', ['month' => $currentMonth])) ?>">
<span class="brand-mark">NL</span>
<span><strong><?= h($appName) ?></strong><small>Financial command center</small></span>
</a>
<nav class="nav">
<a class="<?= $currentRoute === 'dashboard' ? 'active' : '' ?>" href="<?= h(url('dashboard', ['month' => $currentMonth])) ?>"><span>⌂</span> Command center</a>
<a class="<?= $currentRoute === 'transactions' ? 'active' : '' ?>" href="<?= h(url('transactions', ['month' => $currentMonth])) ?>"><span>↗</span> Expenses</a>
<?php if ($settings->module('calendar')): ?>
<a class="<?= $currentRoute === 'calendar' ? 'active' : '' ?>" href="<?= h(url('calendar', ['month' => $currentMonth])) ?>"><span>□</span> Bill calendar</a>
<?php endif; ?>
<?php if ($settings->module('comparisons')): ?>
<a class="<?= $currentRoute === 'comparisons' ? 'active' : '' ?>" href="<?= h(url('comparisons', ['month' => $currentMonth])) ?>"><span>⌁</span> Comparisons</a>
<?php endif; ?>
<?php if ($settings->module('dead_account')): ?>
<a class="<?= $currentRoute === 'dead-account' ? 'active' : '' ?>" href="<?= h(url('dead-account', ['month' => $currentMonth])) ?>"><span>◇</span> Dormant account</a>
<?php endif; ?>
</nav>
<div class="nav-label">Administration</div>
<nav class="nav">
<a class="<?= $currentRoute === 'settings' ? 'active' : '' ?>" href="<?= h(url('settings')) ?>"><span>⚙</span> Settings</a>
<?php if ($settings->module('import_export')): ?>
<a class="<?= $currentRoute === 'data' ? 'active' : '' ?>" href="<?= h(url('data', ['month' => $currentMonth])) ?>"><span>⇄</span> Import & export</a>
<?php endif; ?>
</nav>
<div class="sidebar-footer">
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark mode"><span>◐</span> <span data-theme-label>Light mode</span></button>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="logout">
<button class="text-button" type="submit">Sign out</button>
</form>
</div>
</aside>
<main class="main">
<header class="topbar">
<button class="menu-button" type="button" data-menu-toggle aria-label="Open navigation">☰</button>
<div>
<p class="eyebrow"><?= h(date('l, F j')) ?></p>
<h1><?= h($title ?? 'Planner') ?></h1>
</div>
<?php if (!in_array($currentRoute, ['settings', 'data'], true)): ?>
<div class="month-switcher">
<a aria-label="Previous month" href="<?= h(url($currentRoute, ['month' => $previousMonth])) ?>">←</a>
<label>
<span class="sr-only">Budget month</span>
<input type="month" value="<?= h($currentMonth) ?>" data-month-picker data-route="<?= h($currentRoute) ?>">
</label>
<a aria-label="Next month" href="<?= h(url($currentRoute, ['month' => $nextMonth])) ?>">→</a>
</div>
<?php endif; ?>
</header>
<?php foreach ($flashes as $flash): ?>
<div class="flash <?= h($flash['type']) ?>" role="status">
<span><?= $flash['type'] === 'success' ? '✓' : '!' ?></span>
<?= h($flash['message']) ?>
<button type="button" aria-label="Dismiss">×</button>
</div>
<?php endforeach; ?>
<div class="page-content"><?= $content ?></div>
</main>
</div>
<script src="/assets/app.js"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<div class="auth-card">
<p class="eyebrow">Administrator access</p>
<h2>Welcome back</h2>
<p class="muted">Sign in to continue planning.</p>
<form method="post" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="login">
<label>Email address<input type="email" name="email" required autocomplete="email"></label>
<label>Password<input type="password" name="password" required autocomplete="current-password"></label>
<button class="button primary full" type="submit">Sign in</button>
</form>
</div>
+107
View File
@@ -0,0 +1,107 @@
<?php $s = $allSettings; ?>
<?php if ($newSecret): ?>
<div class="setup-secret card glow-green">
<div><p class="eyebrow">Authenticator setup</p><h2>Store this key now</h2><p>Add the key manually in an authenticator app. It is only shown once.</p></div>
<code><?= h($newSecret) ?></code>
<details><summary>Authenticator URI</summary><code class="wrap-code"><?= h($totpUri) ?></code></details>
</div>
<?php endif; ?>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="action" value="settings.save">
<section class="settings-grid">
<article class="card settings-nav">
<p class="eyebrow">Configuration</p>
<a href="#general">General</a><a href="#modules">Modules</a><a href="#dormant">Dormant rules</a><a href="#alerts">Alerts & Mailgun</a><a href="#security">Security</a>
</article>
<div class="stack span-2">
<article class="card" id="general">
<div class="card-heading"><div><p class="eyebrow">Identity and display</p><h2>General</h2></div></div>
<div class="form-grid">
<label class="span-2">Planner name<input type="text" name="app.name" value="<?= h($s['app.name']) ?>" required></label>
<label>Currency symbol<input type="text" name="app.currency_symbol" value="<?= h($s['app.currency_symbol']) ?>" maxlength="4" required></label>
<label>Default theme<select name="theme.default"><option value="dark" <?= $s['theme.default'] === 'dark' ? 'selected' : '' ?>>Dark</option><option value="light" <?= $s['theme.default'] === 'light' ? 'selected' : '' ?>>Light</option></select></label>
<label class="span-2">Timezone<input type="text" name="app.timezone" value="<?= h($s['app.timezone']) ?>" placeholder="America/New_York"></label>
</div>
</article>
<article class="card" id="modules">
<div class="card-heading"><div><p class="eyebrow">Modular planner</p><h2>Enabled modules</h2></div></div>
<div class="toggle-grid">
<?php
$moduleLabels = [
'income' => ['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]):
?>
<label class="toggle-card"><span><strong><?= h($label) ?></strong><small><?= h($description) ?></small></span><input type="checkbox" name="modules_<?= h($key) ?>" value="1" <?= !empty($s['modules.' . $key]) ? 'checked' : '' ?>><i></i></label>
<?php endforeach; ?>
</div>
</article>
<article class="card" id="dormant">
<div class="card-heading"><div><p class="eyebrow">Automated set-aside</p><h2>Dormant account rules</h2></div></div>
<div class="form-grid">
<label>Income rule<select name="dead.income_mode"><option value="percent" <?= $s['dead.income_mode'] === 'percent' ? 'selected' : '' ?>>Percentage</option><option value="fixed" <?= $s['dead.income_mode'] === 'fixed' ? 'selected' : '' ?>>Direct amount</option></select></label>
<label>Income value<input type="number" name="dead.income_value" min="0" step="0.01" value="<?= h((string) $s['dead.income_value']) ?>"></label>
<label>Per-transaction rule<select name="dead.transaction_mode"><option value="percent" <?= $s['dead.transaction_mode'] === 'percent' ? 'selected' : '' ?>>Percentage</option><option value="fixed" <?= $s['dead.transaction_mode'] === 'fixed' ? 'selected' : '' ?>>Direct amount</option></select></label>
<label>Per-transaction value<input type="number" name="dead.transaction_value" min="0" step="0.01" value="<?= h((string) $s['dead.transaction_value']) ?>"></label>
<label class="check-row span-2"><input type="checkbox" name="dead_hide_balance" value="1" <?= $s['dead.hide_balance'] ? 'checked' : '' ?>><span><strong>Conceal dormant balance</strong><small>Require a hover and confirmation click to reveal it.</small></span></label>
</div>
</article>
<article class="card" id="alerts">
<div class="card-heading"><div><p class="eyebrow">Upcoming bills</p><h2>Email alerts via Mailgun</h2></div><span class="status-badge <?= $mailgunConfigured ? 'success' : 'warning' ?>"><?= $mailgunConfigured ? 'Configured' : 'Needs setup' ?></span></div>
<div class="form-grid">
<label class="span-2">Alert recipient<input type="email" name="alerts.email" value="<?= h($s['alerts.email']) ?>" placeholder="alerts@example.com"></label>
<label>Alert windows (days)<input type="text" name="alerts.windows" value="<?= h(implode(', ', (array) $s['alerts.windows'])) ?>" placeholder="7, 3, 1"></label>
<label>Reminder times<input type="text" name="alerts.times" value="<?= h(implode(', ', (array) $s['alerts.times'])) ?>" placeholder="09:00, 17:30"></label>
<label>Mailgun domain<input type="text" name="mailgun.domain" value="<?= h($s['mailgun.domain']) ?>" placeholder="mg.example.com"></label>
<label>Mailgun region<select name="mailgun.region"><option value="us" <?= $s['mailgun.region'] === 'us' ? 'selected' : '' ?>>United States</option><option value="eu" <?= $s['mailgun.region'] === 'eu' ? 'selected' : '' ?>>European Union</option></select></label>
<label class="span-2">From address<input type="text" name="mailgun.from" value="<?= h($s['mailgun.from']) ?>"></label>
<label class="span-2">Mailgun API key<input type="password" name="mailgun.api_key" placeholder="<?= $s['mailgun.api_key'] ? 'Stored — enter a value to replace it' : 'key-…' ?>" autocomplete="off"></label>
<?php if ($s['mailgun.api_key']): ?><label class="check-row span-2 slim"><input type="checkbox" name="clear_mailgun_key" value="1"><span>Clear the stored API key</span></label><?php endif; ?>
</div>
<p class="form-note">Run <code>php scripts/send-reminders.php</code> every minute with cron; it only sends at configured times.</p>
</article>
<article class="card" id="security">
<div class="card-heading"><div><p class="eyebrow">Administrator protection</p><h2>Security</h2></div></div>
<label class="check-row"><input type="checkbox" name="security_2fa_required" value="1" <?= $s['security.2fa_required'] ? 'checked' : '' ?>><span><strong>Require two-factor authentication</strong><small>Uses any standard TOTP authenticator app.</small></span></label>
<p class="form-note">Administrator: <?= h($user['email']) ?> · account created <?= h(date('M j, Y', strtotime($user['created_at']))) ?></p>
</article>
<button class="button primary sticky-save" type="submit">Save all settings</button>
</div>
</section>
</form>
<section class="settings-actions">
<form method="post">
<?= csrf_field() ?><input type="hidden" name="action" value="2fa.regenerate">
<button class="button secondary" type="submit">Generate new 2FA key</button>
</form>
<?php if ($settings->module('alerts')): ?>
<form method="post" data-confirm="Run the reminder scan now? Configured emails may be sent through Mailgun.">
<?= csrf_field() ?><input type="hidden" name="action" value="alerts.run">
<button class="button secondary" type="submit">Run reminders now</button>
</form>
<?php endif; ?>
</section>
<article class="card">
<div class="card-heading"><div><p class="eyebrow">Organize spending</p><h2>Add an expense category</h2></div></div>
<form method="post" class="inline-fields">
<?= csrf_field() ?><input type="hidden" name="action" value="category.add">
<label>Name<input type="text" name="name" required placeholder="Utilities"></label>
<label>Color<input type="color" name="color" value="#7c5cff"></label>
<label>Optional monthly limit<input type="number" name="monthly_limit" min="0" step="0.01" value="0"></label>
<button class="button primary" type="submit">Add category</button>
</form>
</article>
+14
View File
@@ -0,0 +1,14 @@
<div class="auth-card">
<p class="eyebrow">First run</p>
<h2>Create the administrator</h2>
<p class="muted">This account controls the planner, its modules, exports, and alert settings.</p>
<form method="post" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="setup">
<label>Email address<input type="email" name="email" required autocomplete="email" placeholder="you@example.com"></label>
<label>Password<input type="password" name="password" required minlength="10" autocomplete="new-password" placeholder="At least 10 characters"></label>
<label>Confirm password<input type="password" name="password_confirmation" required minlength="10" autocomplete="new-password"></label>
<label class="check-row"><input type="checkbox" name="enable_2fa" value="1"><span><strong>Enable optional 2FA now</strong><small>You will receive a setup key after account creation.</small></span></label>
<button class="button primary full" type="submit">Create planner</button>
</form>
</div>
+56
View File
@@ -0,0 +1,56 @@
<section class="summary-strip">
<div><span>Available this month</span><strong><?= h(Support::money($summary['gross_resources'], $symbol)) ?></strong></div>
<div><span>Total spent</span><strong class="pink"><?= h(Support::money($summary['expenses'], $symbol)) ?></strong></div>
<div><span>Remaining</span><strong class="<?= $summary['remaining'] >= 0 ? 'green' : 'red' ?>"><?= h(Support::money($summary['remaining'], $symbol)) ?></strong></div>
<button class="button primary" type="button" data-dialog-open="expense-dialog">+ Record expense</button>
</section>
<article class="card">
<div class="card-heading">
<div><p class="eyebrow">Searchable ledger</p><h2>Expense history</h2></div>
<label class="search-box"><span>⌕</span><input type="search" placeholder="Search expenses…" data-table-search="expense-table"></label>
</div>
<?php if ($transactions === []): ?>
<div class="empty-state"><strong>No expenses recorded</strong><p>Record a payment to move a bills progress forward.</p></div>
<?php else: ?>
<div class="table-wrap">
<table id="expense-table">
<thead><tr><th>Date</th><th>Description</th><th>Category / item</th><th>Amount</th><th>Dormant pull</th><th></th></tr></thead>
<tbody>
<?php foreach ($transactions as $transaction): ?>
<tr>
<td data-label="Date"><?= h(date('M j, Y', strtotime($transaction['transacted_on']))) ?></td>
<td data-label="Description"><strong><?= h($transaction['merchant']) ?></strong><?php if ($transaction['notes']): ?><small><?= h($transaction['notes']) ?></small><?php endif; ?></td>
<td data-label="Category"><span class="pill"><i style="--dot:<?= h($transaction['category_color'] ?: '#718096') ?>"></i><?= h($transaction['category_name'] ?: 'Uncategorized') ?></span><?php if ($transaction['item_name']): ?><small><?= h($transaction['item_name']) ?></small><?php endif; ?></td>
<td data-label="Amount"><strong><?= h(Support::money($transaction['amount'], $symbol)) ?></strong></td>
<td data-label="Dormant pull"><?= h(Support::money($transaction['dead_pull_amount'], $symbol)) ?></td>
<td>
<form method="post" data-confirm="Delete this expense and reverse its dormant transfer?">
<?= csrf_field() ?><input type="hidden" name="action" value="transaction.delete"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $transaction['id'] ?>">
<button class="icon-button danger" type="submit" title="Delete">×</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</article>
<dialog id="expense-dialog">
<form method="post" class="dialog-card">
<?= csrf_field() ?><input type="hidden" name="action" value="transaction.add"><input type="hidden" name="month" value="<?= h($month) ?>">
<div class="card-heading"><div><p class="eyebrow">Progress a target</p><h2>Record expense</h2></div><button class="icon-button" type="button" data-dialog-close>×</button></div>
<div class="form-grid">
<label class="span-2">Description<input type="text" name="merchant" required placeholder="Payment or merchant"></label>
<label>Amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="amount" min="0.01" step="0.01" required></div></label>
<label>Date<input type="date" name="transacted_on" value="<?= h(date('Y-m-d')) ?>" required></label>
<label>Budget item<select name="budget_item_id"><option value="">General expense</option><?php foreach ($items as $item): ?><option value="<?= (int) $item['id'] ?>"><?= h($item['name']) ?> (<?= h(Support::money($item['remaining_amount'], $symbol)) ?> owing)</option><?php endforeach; ?></select></label>
<label>Category<select name="category_id"><option value="">Use item / uncategorized</option><?php foreach ($categories as $category): ?><option value="<?= (int) $category['id'] ?>"><?= h($category['name']) ?></option><?php endforeach; ?></select></label>
<label class="span-2">Notes<textarea name="notes" rows="2"></textarea></label>
</div>
<p class="form-note">A configured per-transaction amount or percentage will also be moved to the dormant account.</p>
<button class="button primary full" type="submit">Record expense</button>
</form>
</dialog>
+11
View File
@@ -0,0 +1,11 @@
<div class="auth-card">
<p class="eyebrow">Second step</p>
<h2>Authentication code</h2>
<p class="muted">Enter the current six-digit code from your authenticator.</p>
<form method="post" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="verify_2fa">
<label>Six-digit code<input class="otp-input" type="text" name="code" required inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" autofocus></label>
<button class="button primary full" type="submit">Verify and continue</button>
</form>
</div>