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.
This commit is contained in:
Ty Clifford
2026-06-15 10:33:55 -04:00
parent b7eaa81501
commit 51ee4ecb5a
18 changed files with 1568 additions and 559 deletions
+372 -27
View File
@@ -107,7 +107,9 @@ final class BudgetService
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.');
@@ -150,6 +152,58 @@ final class BudgetService
}
}
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();
@@ -196,6 +250,31 @@ final class BudgetService
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,
@@ -212,6 +291,9 @@ final class BudgetService
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)
@@ -236,6 +318,56 @@ final class BudgetService
$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(
@@ -269,7 +401,9 @@ final class BudgetService
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.');
}
@@ -277,7 +411,10 @@ final class BudgetService
$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) {
if ($itemCategory === false) {
throw new RuntimeException('The selected budget item is not part of this month.');
}
if (!$categoryId) {
$categoryId = $itemCategory ? (int) $itemCategory : null;
}
}
@@ -322,6 +459,77 @@ final class BudgetService
}
}
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();
@@ -351,8 +559,16 @@ final class BudgetService
return $statement->fetchAll();
}
public function moveDeadAccount(string $month, string $direction, float $amount, string $notes): int
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.');
@@ -361,25 +577,53 @@ final class BudgetService
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, 'manual', null, trim($notes) ?: ucfirst(str_replace('_', ' ', $kind)));
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 * FROM dead_account_entries WHERE month = :month ORDER BY created_at DESC, id DESC LIMIT ' . (int) $limit
'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 * FROM dead_account_entries ORDER BY created_at DESC, id DESC LIMIT ' . (int) $limit
'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();
}
@@ -390,6 +634,8 @@ final class BudgetService
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.');
}
@@ -437,30 +683,39 @@ final class BudgetService
if (!$plan) {
return [
'month' => $month, 'starting_income' => 0.0, 'rollover_in' => 0.0,
'additional_income' => 0.0, 'gross_resources' => 0.0, 'expenses' => 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,
'paid_to_items' => 0.0, 'unallocated' => 0.0, 'savings_rate' => 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);
$gross = (float) $plan['starting_income'] + (float) $plan['rollover_in'] + $income;
$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' => $gross - $planned - max(0, $deadMovement),
'savings_rate' => $gross > 0 ? max(0, $deadMovement) / $gross * 100 : 0.0,
'unallocated' => $usableBeforeExpenses - $planned,
'savings_rate' => $freshIncome > 0 ? $deadDeposits / $freshIncome * 100 : 0.0,
'budget_coverage' => $planned > 0 ? min(100, $usableBeforeExpenses / $planned * 100) : 100.0,
];
}
@@ -478,9 +733,13 @@ final class BudgetService
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,
];
@@ -499,18 +758,78 @@ final class BudgetService
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
'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(['month' => $month]);
return $statement->fetchAll();
$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
@@ -585,19 +904,45 @@ final class BudgetService
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');
$sources = [
['month' => $targetDate->modify('-1 month')->format('Y-m'), 'recurrence' => 'monthly'],
['month' => $targetDate->modify('-1 year')->format('Y-m'), 'recurrence' => 'yearly'],
$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 ($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 ($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) {