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 month’s 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'], ]); } } } }