settings->get('modules.alerts', true)) { return ['sent' => 0, 'skipped' => 0, 'errors' => ['Alerts module is disabled.']]; } $times = (array) $this->settings->get('alerts.times', ['09:00']); if (!$force && !in_array(date('H:i'), $times, true)) { return ['sent' => 0, 'skipped' => 0, 'errors' => []]; } $recipient = (string) $this->settings->get('alerts.email', ''); $windows = array_map('intval', (array) $this->settings->get('alerts.windows', [7, 3, 1])); $today = new DateTimeImmutable('today'); $sent = 0; $skipped = 0; $errors = []; foreach ($windows as $window) { $target = $today->modify('+' . max(0, $window) . ' days')->format('Y-m-d'); $statement = $this->pdo->prepare( 'SELECT bi.*, c.name AS category_name FROM budget_items bi LEFT JOIN categories c ON c.id = bi.category_id WHERE bi.due_date = :due_date AND bi.status = :status' ); $statement->execute(['due_date' => $target, 'status' => 'active']); foreach ($statement->fetchAll() as $item) { if ($this->alreadyLogged((int) $item['id'], $today->format('Y-m-d'), $window, $recipient)) { $skipped++; continue; } try { $subject = sprintf('%s due in %d day%s', $item['name'], $window, $window === 1 ? '' : 's'); $html = sprintf( '
Your %s budget item is due on %s.
Planned amount: %s%.2f
', htmlspecialchars($subject, ENT_QUOTES), htmlspecialchars((string) ($item['category_name'] ?: 'uncategorized'), ENT_QUOTES), htmlspecialchars((string) $item['due_date'], ENT_QUOTES), htmlspecialchars((string) $this->settings->get('app.currency_symbol', '$'), ENT_QUOTES), (float) $item['planned_amount'] ); $response = $this->mailgun->send($recipient, $subject, $html); $this->log((int) $item['id'], $today->format('Y-m-d'), $window, $recipient, 'sent', (string) $response['body']); $sent++; } catch (\Throwable $exception) { $this->log((int) $item['id'], $today->format('Y-m-d'), $window, $recipient, 'failed', $exception->getMessage()); $errors[] = $item['name'] . ': ' . $exception->getMessage(); } } } return ['sent' => $sent, 'skipped' => $skipped, 'errors' => $errors]; } private function alreadyLogged(int $itemId, string $date, int $window, string $recipient): bool { $statement = $this->pdo->prepare( 'SELECT COUNT(*) FROM alert_logs WHERE budget_item_id = :item_id AND alert_date = :alert_date AND window_days = :window_days AND recipient = :recipient' ); $statement->execute([ 'item_id' => $itemId, 'alert_date' => $date, 'window_days' => $window, 'recipient' => $recipient, ]); return (int) $statement->fetchColumn() > 0; } private function log(int $itemId, string $date, int $window, string $recipient, string $status, string $response): void { $statement = $this->pdo->prepare( 'INSERT INTO alert_logs (budget_item_id, alert_date, window_days, recipient, status, response) VALUES (:item_id, :alert_date, :window_days, :recipient, :status, :response)' ); $statement->execute([ 'item_id' => $itemId, 'alert_date' => $date, 'window_days' => $window, 'recipient' => $recipient, 'status' => $status, 'response' => $response, ]); } }