Files
budget/app/BudgetService.php
T
Ty Clifford 51ee4ecb5a -
Finished the planner end to end.
Corrected rollover and yearly accounting, recurring targets, savings 
rules, and targeted dormant restorations.
Added editing/deletion for income, expenses, targets, and categories.
Rebuilt dashboard, analytics, calendar, settings, exports, 
authentication, and responsive mobile UI.
Added original artwork at 
[financial-flow.png](/Users/tyemeclifford/Documents/GH/budget/public/assets/images/financial-flow.png), 
generated with built-in Imagegen using an abstract financial-momentum 
landscape prompt.
Added 
[self-test.php](/Users/tyemeclifford/Documents/GH/budget/scripts/self-test.php).
Verification passed: automated accounting/export tests, full browser 
workflow, responsive 390px layout, light/dark themes, and no console 
errors. Tested with FrankenPHP v1.12.4. Live Mailgun delivery was not 
attempted without credentials.
2026-06-15 10:33:55 -04:00

976 lines
40 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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
{
$month = Support::month($month);
$this->ensureMonth($month);
$this->assertDateInMonth($receivedOn, $month, 'Income date');
$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 updateIncome(int $id, string $month, string $label, float $amount, string $receivedOn, string $notes): void
{
$month = Support::month($month);
$this->assertDateInMonth($receivedOn, $month, 'Income date');
$amount = max(0, $amount);
if ($id <= 0 || $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(
'UPDATE income_entries
SET month = :month, label = :label, amount = :amount, received_on = :received_on,
withholding_amount = :withholding, notes = :notes
WHERE id = :id'
);
$statement->execute([
'month' => $month,
'label' => trim($label),
'amount' => $amount,
'received_on' => $receivedOn,
'withholding' => $withholding,
'notes' => trim($notes) ?: null,
'id' => $id,
]);
if ($statement->rowCount() === 0) {
$check = $this->pdo->prepare('SELECT COUNT(*) FROM income_entries WHERE id = :id');
$check->execute(['id' => $id]);
if ((int) $check->fetchColumn() === 0) {
throw new RuntimeException('Income entry was not found.');
}
}
$this->replaceDeadSource(
$month,
'income_withholding',
$withholding,
'income',
$id,
'Automatic withholding from ' . trim($label)
);
$this->pdo->commit();
} 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 updateCategory(int $id, string $name, string $color, float $monthlyLimit): void
{
if ($id <= 0 || trim($name) === '') {
throw new RuntimeException('Category name is required.');
}
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
$color = '#7c5cff';
}
$statement = $this->pdo->prepare(
'UPDATE categories SET name = :name, color = :color, monthly_limit = :monthly_limit WHERE id = :id'
);
$statement->execute([
'name' => trim($name),
'color' => $color,
'monthly_limit' => max(0, $monthlyLimit),
'id' => $id,
]);
}
public function archiveCategory(int $id): void
{
$statement = $this->pdo->prepare('UPDATE categories SET active = 0 WHERE id = :id');
$statement->execute(['id' => $id]);
}
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';
}
if ($dueDate !== null) {
$this->assertDateInMonth($dueDate, Support::month($month), 'Due date');
}
$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 updateBudgetItem(
int $id,
string $month,
?int $categoryId,
string $name,
float $plannedAmount,
?string $dueDate,
string $recurrence,
string $notes
): void {
if ($id <= 0 || 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';
}
if ($dueDate !== null) {
$this->assertDateInMonth($dueDate, Support::month($month), 'Due date');
}
$existing = $this->pdo->prepare('SELECT series_key FROM budget_items WHERE id = :id AND month = :month');
$existing->execute(['id' => $id, 'month' => Support::month($month)]);
$series = $existing->fetchColumn();
if ($series === false) {
throw new RuntimeException('Budget item was not found.');
}
if ($recurrence !== 'none' && !$series) {
$series = bin2hex(random_bytes(12));
}
if ($recurrence === 'none') {
$series = null;
}
$statement = $this->pdo->prepare(
'UPDATE budget_items
SET category_id = :category_id, name = :name, planned_amount = :planned_amount,
due_date = :due_date, recurrence = :recurrence, series_key = :series_key, notes = :notes
WHERE id = :id AND month = :month'
);
$statement->execute([
'category_id' => $categoryId ?: null,
'name' => trim($name),
'planned_amount' => max(0, $plannedAmount),
'due_date' => $dueDate ?: null,
'recurrence' => $recurrence,
'series_key' => $series ?: null,
'notes' => trim($notes) ?: null,
'id' => $id,
'month' => Support::month($month),
]);
}
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 {
$month = Support::month($month);
$this->ensureMonth($month);
$this->assertDateInMonth($date, $month, 'Expense date');
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) {
throw new RuntimeException('The selected budget item is not part of this month.');
}
if (!$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 updateTransaction(
int $id,
string $month,
?int $budgetItemId,
?int $categoryId,
string $merchant,
float $amount,
string $date,
string $notes
): void {
$month = Support::month($month);
$this->assertDateInMonth($date, $month, 'Expense date');
if ($id <= 0 || trim($merchant) === '' || $amount <= 0) {
throw new RuntimeException('An expense needs a description and an amount greater than zero.');
}
$existing = $this->pdo->prepare('SELECT COUNT(*) FROM transactions WHERE id = :id');
$existing->execute(['id' => $id]);
if ((int) $existing->fetchColumn() === 0) {
throw new RuntimeException('Expense was not found.');
}
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) {
throw new RuntimeException('The selected budget item is not part of this month.');
}
if (!$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(
'UPDATE transactions
SET month = :month, budget_item_id = :budget_item_id, category_id = :category_id,
merchant = :merchant, amount = :amount, transacted_on = :transacted_on,
dead_pull_amount = :dead_pull, notes = :notes
WHERE id = :id'
);
$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' => $id,
]);
$this->replaceDeadSource(
$month,
'transaction_pull',
$deadPull,
'transaction',
$id,
'Automatic dormant transfer for ' . trim($merchant)
);
$this->pdo->commit();
} 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,
string $destinationType = 'income',
?int $destinationId = null
): int
{
$month = Support::month($month);
$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.');
}
$sourceType = 'restore_income';
$sourceId = null;
$destinationLabel = 'monthly income';
if ($destinationType === 'item' && $destinationId) {
$statement = $this->pdo->prepare('SELECT name FROM budget_items WHERE id = :id AND month = :month');
$statement->execute(['id' => $destinationId, 'month' => $month]);
$itemName = $statement->fetchColumn();
if ($itemName === false) {
throw new RuntimeException('The selected restoration target was not found.');
}
$sourceType = 'restore_item';
$sourceId = $destinationId;
$destinationLabel = (string) $itemName;
}
$amount *= -1;
$kind = 'restore';
$notes = trim($notes) ?: 'Restored to ' . $destinationLabel;
} else {
$available = (float) $this->summary($month)['remaining'];
if ($amount > max(0, $available) + 0.0001) {
throw new RuntimeException('That deposit is larger than this months spendable balance.');
}
$kind = 'manual_deposit';
$sourceType = 'manual';
$sourceId = null;
$notes = trim($notes) ?: 'Manual dormant deposit';
}
return $this->insertDeadEntry($month, $kind, $amount, $sourceType, $sourceId, $notes);
}
public function deadEntries(?string $month = null, int $limit = 100): array
{
if ($month) {
$statement = $this->pdo->prepare(
'SELECT dae.*, bi.name AS destination_name
FROM dead_account_entries dae
LEFT JOIN budget_items bi ON dae.source_type = "restore_item" AND bi.id = dae.source_id
WHERE dae.month = :month ORDER BY dae.created_at DESC, dae.id DESC LIMIT ' . (int) $limit
);
$statement->execute(['month' => $month]);
return $statement->fetchAll();
}
return $this->pdo->query(
'SELECT dae.*, bi.name AS destination_name
FROM dead_account_entries dae
LEFT JOIN budget_items bi ON dae.source_type = "restore_item" AND bi.id = dae.source_id
ORDER BY dae.created_at DESC, dae.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
{
$month = Support::month($month);
$this->assertDateInMonth($date, $month, 'Event date');
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, 'fresh_income' => 0.0, 'gross_resources' => 0.0, 'expenses' => 0.0,
'dead_movement' => 0.0, 'remaining' => 0.0, 'planned' => 0.0,
'dead_deposits' => 0.0, 'dead_restorations' => 0.0, 'paid_to_items' => 0.0,
'unallocated' => 0.0, 'savings_rate' => 0.0, 'budget_coverage' => 0.0,
];
}
$income = $this->sum('income_entries', 'amount', $month);
$expenses = $this->sum('transactions', 'amount', $month);
$deadMovement = $this->sum('dead_account_entries', 'amount', $month);
$deadDeposits = $this->sumWhere('dead_account_entries', 'amount', $month, 'amount > 0');
$deadRestorations = abs($this->sumWhere('dead_account_entries', 'amount', $month, 'amount < 0'));
$planned = $this->sum('budget_items', 'planned_amount', $month);
$freshIncome = (float) $plan['starting_income'] + $income;
$gross = $freshIncome + (float) $plan['rollover_in'];
$remaining = $gross - $expenses - $deadMovement;
$usableBeforeExpenses = $gross - $deadMovement;
return [
'month' => $month,
'starting_income' => (float) $plan['starting_income'],
'rollover_in' => (float) $plan['rollover_in'],
'additional_income' => $income,
'fresh_income' => $freshIncome,
'gross_resources' => $gross,
'expenses' => $expenses,
'dead_movement' => $deadMovement,
'dead_deposits' => $deadDeposits,
'dead_restorations' => $deadRestorations,
'remaining' => $remaining,
'planned' => $planned,
'paid_to_items' => $this->sumWhere('transactions', 'amount', $month, 'budget_item_id IS NOT NULL'),
'unallocated' => $usableBeforeExpenses - $planned,
'savings_rate' => $freshIncome > 0 ? $deadDeposits / $freshIncome * 100 : 0.0,
'budget_coverage' => $planned > 0 ? min(100, $usableBeforeExpenses / $planned * 100) : 100.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 = [
'fresh_income' => 0.0,
'rollover_in' => 0.0,
'gross_resources' => 0.0,
'expenses' => 0.0,
'dead_movement' => 0.0,
'dead_deposits' => 0.0,
'dead_restorations' => 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 c.id, c.name AS category_name, c.color, c.monthly_limit,
COALESCE(SUM(t.amount), 0) AS spent,
COALESCE((SELECT SUM(bi.planned_amount) FROM budget_items bi WHERE bi.month = :item_month AND bi.category_id = c.id), 0) AS planned
FROM categories c
LEFT JOIN transactions t ON t.category_id = c.id AND t.month = :transaction_month
WHERE c.active = 1
GROUP BY c.id, c.name, c.color, c.monthly_limit
UNION ALL
SELECT NULL AS id, "Uncategorized" AS category_name, "#718096" AS color, 0 AS monthly_limit,
COALESCE(SUM(t.amount), 0) AS spent,
COALESCE((SELECT SUM(bi.planned_amount) FROM budget_items bi WHERE bi.month = :uncategorized_item_month AND bi.category_id IS NULL), 0) AS planned
FROM transactions t
WHERE t.month = :uncategorized_transaction_month AND t.category_id IS NULL
HAVING COALESCE(SUM(t.amount), 0) > 0
ORDER BY spent DESC'
);
$statement->execute([
'item_month' => $month,
'transaction_month' => $month,
'uncategorized_item_month' => $month,
'uncategorized_transaction_month' => $month,
]);
return array_values(array_filter(
$statement->fetchAll(),
static fn (array $category): bool => (float) $category['spent'] > 0
|| (float) $category['planned'] > 0
|| (float) $category['monthly_limit'] > 0
));
}
public function upcomingBills(string $month, int $limit = 6): array
{
$items = array_values(array_filter(
$this->budgetItems($month),
static fn (array $item): bool => $item['due_date'] !== null && (float) $item['remaining_amount'] > 0
));
usort($items, static fn (array $left, array $right): int => strcmp((string) $left['due_date'], (string) $right['due_date']));
return array_slice($items, 0, max(1, $limit));
}
public function recentActivity(string $month, int $limit = 8): array
{
$activity = [];
foreach ($this->transactions($month) as $transaction) {
$activity[] = [
'type' => 'expense',
'title' => $transaction['merchant'],
'detail' => $transaction['category_name'] ?: 'Uncategorized',
'amount' => -(float) $transaction['amount'],
'date' => $transaction['transacted_on'],
];
}
foreach ($this->incomes($month) as $income) {
$activity[] = [
'type' => 'income',
'title' => $income['label'],
'detail' => 'Income received',
'amount' => (float) $income['amount'],
'date' => $income['received_on'],
];
}
foreach ($this->deadEntries($month, 100) as $entry) {
$activity[] = [
'type' => (float) $entry['amount'] >= 0 ? 'saved' : 'restored',
'title' => (float) $entry['amount'] >= 0 ? 'Moved to dormant' : 'Restored from dormant',
'detail' => $entry['notes'] ?: ucwords(str_replace('_', ' ', $entry['kind'])),
'amount' => -(float) $entry['amount'],
'date' => substr((string) $entry['created_at'], 0, 10),
];
}
usort($activity, static fn (array $left, array $right): int => strcmp($right['date'], $left['date']));
return array_slice($activity, 0, max(1, $limit));
}
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 assertDateInMonth(string $date, string $month, string $label): void
{
$parts = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches) ? $matches : null;
if ($parts === null
|| !checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])
|| substr($date, 0, 7) !== $month
) {
throw new RuntimeException($label . ' must fall within ' . date('F Y', strtotime($month . '-01')) . '.');
}
}
private function materializeRecurringItems(string $month): void
{
$targetDate = new DateTimeImmutable($month . '-01');
$targetMonthNumber = $targetDate->format('m');
$statements = [
[
'sql' => 'SELECT * FROM budget_items
WHERE month < :month AND recurrence = :recurrence AND series_key IS NOT NULL
ORDER BY month DESC, id DESC',
'parameters' => ['month' => $month, 'recurrence' => 'monthly'],
],
[
'sql' => 'SELECT * FROM budget_items
WHERE month < :month AND SUBSTR(month, 6, 2) = :month_number
AND recurrence = :recurrence AND series_key IS NOT NULL
ORDER BY month DESC, id DESC',
'parameters' => ['month' => $month, 'month_number' => $targetMonthNumber, 'recurrence' => 'yearly'],
],
];
foreach ($statements as $source) {
$statement = $this->pdo->prepare($source['sql']);
$statement->execute($source['parameters']);
$seen = [];
foreach ($statement->fetchAll() as $item) {
if (isset($seen[$item['series_key']])) {
continue;
}
$seen[$item['series_key']] = true;
$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'],
]);
}
}
}
}