1.0, 'USD' => 0.92, 'BRL' => 0.17, 'GBP' => 1.16, ]; public function handle(): void { $currentTime = now()->format('H:i'); $this->info("Running due payments notification at {$currentTime}"); // Get users with notification enabled $query = UserPreference::where('notify_due_payments', true); if ($userId = $this->option('user')) { $query->where('user_id', $userId); } // Filter by time (within 5 minute window) unless forced if (!$this->option('force')) { $query->whereRaw("TIME_FORMAT(notify_due_payments_time, '%H:%i') = ?", [$currentTime]); } $preferences = $query->with('user')->get(); if ($preferences->isEmpty()) { $this->info('No users to notify at this time.'); return; } foreach ($preferences as $preference) { $this->processUser($preference); } $this->info('Done!'); } private function processUser(UserPreference $preference): void { $user = $preference->user; $this->info("Processing user: {$user->name} ({$user->email})"); $today = now()->format('Y-m-d'); $tomorrow = now()->addDay()->format('Y-m-d'); // 1. Get all account balances $accountBalances = $this->getAccountBalances($user->id); $totalAvailable = array_sum(array_column($accountBalances, 'balance_converted')); // 2. Get overdue items (date < today) $overdueItems = $this->getOverdueItems($user->id, $today); // 3. Get tomorrow items (date = tomorrow) $tomorrowItems = $this->getTomorrowItems($user->id, $tomorrow); // If no items, skip if (empty($overdueItems) && empty($tomorrowItems)) { $this->info(" No overdue or tomorrow items for {$user->name}"); return; } // 4. Calculate totals $allItems = array_merge($overdueItems, $tomorrowItems); $totalDue = array_sum(array_column($allItems, 'amount_converted')); $shortage = max(0, $totalDue - $totalAvailable); // 5. Determine which items can be paid (priority: most overdue first) usort($allItems, fn($a, $b) => $b['days_overdue'] <=> $a['days_overdue']); $payableItems = []; $unpayableItems = []; $remainingBalance = $totalAvailable; foreach ($allItems as $item) { if ($remainingBalance >= $item['amount_converted']) { $item['can_pay'] = true; $payableItems[] = $item; $remainingBalance -= $item['amount_converted']; } else { $item['can_pay'] = false; $item['partial_available'] = $remainingBalance > 0 ? $remainingBalance : 0; $unpayableItems[] = $item; } } // 6. Suggest transfers if needed $transferSuggestions = $this->suggestTransfers($accountBalances, $payableItems); // 7. Get primary currency $primaryCurrency = $this->getPrimaryCurrency($user->id); // 8. Send email $email = $preference->getNotificationEmail(); try { Mail::to($email)->send(new DuePaymentsAlert( userName: $user->name, overdueItems: $overdueItems, tomorrowItems: $tomorrowItems, accountBalances: $accountBalances, totalAvailable: round($totalAvailable, 2), totalDue: round($totalDue, 2), shortage: round($shortage, 2), payableItems: $payableItems, unpayableItems: $unpayableItems, transferSuggestions: $transferSuggestions, currency: $primaryCurrency )); $this->info(" ✓ Email sent to {$email}"); } catch (\Exception $e) { $this->error(" ✗ Failed to send email: " . $e->getMessage()); \Log::error("DuePaymentsAlert failed for user {$user->id}: " . $e->getMessage()); } } private function getAccountBalances(int $userId): array { $accounts = DB::select(" SELECT a.id, a.name, a.currency, COALESCE( (SELECT SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE -t.amount END) FROM transactions t WHERE t.account_id = a.id AND t.status = 'completed' AND t.deleted_at IS NULL), 0 ) + COALESCE(a.initial_balance, 0) as balance FROM accounts a WHERE a.user_id = ? AND a.deleted_at IS NULL ORDER BY a.name ", [$userId]); $result = []; foreach ($accounts as $account) { $balanceConverted = $this->convertToEUR($account->balance, $account->currency); $result[] = [ 'id' => $account->id, 'name' => $account->name, 'currency' => $account->currency, 'balance' => round($account->balance, 2), 'balance_converted' => round($balanceConverted, 2), ]; } return $result; } private function getOverdueItems(int $userId, string $today): array { $items = []; // 1. Transactions pending/scheduled with planned_date < today $transactions = DB::select(" SELECT t.id, t.description, COALESCE(t.planned_amount, t.amount) as amount, t.planned_date as due_date, COALESCE(a.currency, 'EUR') as currency, a.name as account_name, a.id as account_id, DATEDIFF(?, t.planned_date) as days_overdue, 'transaction' as source_type FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.status IN ('pending', 'scheduled') AND t.type = 'debit' AND t.planned_date < ? AND t.deleted_at IS NULL AND t.is_transfer = 0 ORDER BY t.planned_date ASC ", [$today, $userId, $today]); foreach ($transactions as $t) { $items[] = $this->formatItem($t, 'overdue'); } // 2. Liability installments with due_date < today $installments = DB::select(" SELECT li.id, CONCAT(la.name, ' (Parcela)') as description, li.installment_amount as amount, li.due_date, la.currency, a.name as account_name, la.account_id, DATEDIFF(?, li.due_date) as days_overdue, 'liability_installment' as source_type FROM liability_installments li JOIN liability_accounts la ON li.liability_account_id = la.id LEFT JOIN accounts a ON la.account_id = a.id WHERE la.user_id = ? AND li.status = 'pending' AND li.due_date < ? AND li.deleted_at IS NULL ORDER BY li.due_date ASC ", [$today, $userId, $today]); foreach ($installments as $i) { $items[] = $this->formatItem($i, 'overdue'); } // 3. Recurring instances pending with due_date < today $recurring = DB::select(" SELECT ri.id, CONCAT(rt.name, ' (Recorrente)') as description, ri.planned_amount as amount, ri.due_date, COALESCE(a.currency, 'EUR') as currency, a.name as account_name, rt.account_id, DATEDIFF(?, ri.due_date) as days_overdue, 'recurring_instance' as source_type FROM recurring_instances ri JOIN recurring_templates rt ON ri.recurring_template_id = rt.id LEFT JOIN accounts a ON rt.account_id = a.id WHERE ri.user_id = ? AND ri.status = 'pending' AND rt.type = 'debit' AND ri.due_date < ? AND ri.deleted_at IS NULL ORDER BY ri.due_date ASC ", [$today, $userId, $today]); foreach ($recurring as $r) { $items[] = $this->formatItem($r, 'overdue'); } // Sort by days overdue (most overdue first) usort($items, fn($a, $b) => $b['days_overdue'] <=> $a['days_overdue']); return $items; } private function getTomorrowItems(int $userId, string $tomorrow): array { $items = []; // 1. Transactions pending/scheduled for tomorrow $transactions = DB::select(" SELECT t.id, t.description, COALESCE(t.planned_amount, t.amount) as amount, t.planned_date as due_date, COALESCE(a.currency, 'EUR') as currency, a.name as account_name, a.id as account_id, 0 as days_overdue, 'transaction' as source_type FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.status IN ('pending', 'scheduled') AND t.type = 'debit' AND t.planned_date = ? AND t.deleted_at IS NULL AND t.is_transfer = 0 ", [$userId, $tomorrow]); foreach ($transactions as $t) { $items[] = $this->formatItem($t, 'tomorrow'); } // 2. Liability installments for tomorrow $installments = DB::select(" SELECT li.id, CONCAT(la.name, ' (Parcela)') as description, li.installment_amount as amount, li.due_date, la.currency, a.name as account_name, la.account_id, 0 as days_overdue, 'liability_installment' as source_type FROM liability_installments li JOIN liability_accounts la ON li.liability_account_id = la.id LEFT JOIN accounts a ON la.account_id = a.id WHERE la.user_id = ? AND li.status = 'pending' AND li.due_date = ? AND li.deleted_at IS NULL ", [$userId, $tomorrow]); foreach ($installments as $i) { $items[] = $this->formatItem($i, 'tomorrow'); } // 3. Recurring instances for tomorrow $recurring = DB::select(" SELECT ri.id, CONCAT(rt.name, ' (Recorrente)') as description, ri.planned_amount as amount, ri.due_date, COALESCE(a.currency, 'EUR') as currency, a.name as account_name, rt.account_id, 0 as days_overdue, 'recurring_instance' as source_type FROM recurring_instances ri JOIN recurring_templates rt ON ri.recurring_template_id = rt.id LEFT JOIN accounts a ON rt.account_id = a.id WHERE ri.user_id = ? AND ri.status = 'pending' AND rt.type = 'debit' AND ri.due_date = ? AND ri.deleted_at IS NULL ", [$userId, $tomorrow]); foreach ($recurring as $r) { $items[] = $this->formatItem($r, 'tomorrow'); } return $items; } private function formatItem($row, string $status): array { $amount = abs($row->amount); $amountConverted = $this->convertToEUR($amount, $row->currency); return [ 'id' => $row->id, 'description' => $row->description, 'amount' => round($amount, 2), 'amount_converted' => round($amountConverted, 2), 'currency' => $row->currency, 'due_date' => $row->due_date, 'days_overdue' => (int) $row->days_overdue, 'account_name' => $row->account_name ?? 'Sem conta', 'account_id' => $row->account_id ?? null, 'source_type' => $row->source_type, 'status' => $status, // 'overdue' or 'tomorrow' ]; } private function suggestTransfers(array $accountBalances, array $payableItems): array { $suggestions = []; // Group payable items by account $itemsByAccount = []; foreach ($payableItems as $item) { $accountId = $item['account_id'] ?? 0; if (!isset($itemsByAccount[$accountId])) { $itemsByAccount[$accountId] = 0; } $itemsByAccount[$accountId] += $item['amount_converted']; } // Check each account balance vs required $accountBalanceMap = []; foreach ($accountBalances as $account) { $accountBalanceMap[$account['id']] = $account; } foreach ($itemsByAccount as $accountId => $required) { if ($accountId === 0) continue; $account = $accountBalanceMap[$accountId] ?? null; if (!$account) continue; $balance = $account['balance_converted']; if ($balance < $required) { $deficit = $required - $balance; // Find accounts with surplus to transfer from foreach ($accountBalances as $sourceAccount) { if ($sourceAccount['id'] === $accountId) continue; if ($sourceAccount['balance_converted'] <= 0) continue; $availableToTransfer = $sourceAccount['balance_converted']; $transferAmount = min($deficit, $availableToTransfer); if ($transferAmount > 10) { // Only suggest transfers > 10 EUR $suggestions[] = [ 'from_account' => $sourceAccount['name'], 'from_account_id' => $sourceAccount['id'], 'to_account' => $account['name'], 'to_account_id' => $accountId, 'amount' => round($transferAmount, 2), 'reason' => "Para cobrir pagamentos pendentes", ]; $deficit -= $transferAmount; } if ($deficit <= 0) break; } } } return $suggestions; } private function convertToEUR(float $amount, string $currency): float { $rate = $this->exchangeRates[$currency] ?? 1.0; return $amount * $rate; } private function getPrimaryCurrency(int $userId): string { $result = DB::selectOne(" SELECT currency, COUNT(*) as cnt FROM accounts WHERE user_id = ? AND deleted_at IS NULL GROUP BY currency ORDER BY cnt DESC LIMIT 1 ", [$userId]); return $result->currency ?? 'EUR'; } }