631 lines
25 KiB
PHP
631 lines
25 KiB
PHP
<?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'],
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|