## New Features - Email notifications for overdue and upcoming payments - User preferences page for notification settings - Daily scheduler to send alerts at user-configured time - Smart analysis: payable items, transfer suggestions between accounts ## Backend - Migration for user_preferences table - SendDuePaymentsAlert Artisan command - DuePaymentsAlert Mailable with HTML/text templates - UserPreferenceController with test-notification endpoint - Scheduler config for notify:due-payments command ## Frontend - Preferences.jsx page with notification toggle - API service for preferences - Route and menu link for settings - Translations (PT-BR, EN, ES) ## Server - Cron configured for Laravel scheduler Version: 1.44.5
445 lines
16 KiB
PHP
445 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Mail\DuePaymentsAlert;
|
|
use App\Models\User;
|
|
use App\Models\UserPreference;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Carbon\Carbon;
|
|
|
|
class SendDuePaymentsAlert extends Command
|
|
{
|
|
protected $signature = 'notify:due-payments {--user= : Send to specific user ID} {--force : Send even if disabled}';
|
|
protected $description = 'Send email alerts about overdue and upcoming payments';
|
|
|
|
// Exchange rates cache (same as ReportController)
|
|
private array $exchangeRates = [
|
|
'EUR' => 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';
|
|
}
|
|
}
|