-
This commit is contained in:
+114
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')) . ' — ' . $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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user