webmoney/backend/app/Console/Commands/SendDuePaymentsAlert.php
marco 54cccdd095 refactor: migração para desenvolvimento direto no servidor
- Removido README.md padrão do Laravel (backend)
- Removidos scripts de deploy (não mais necessários)
- Atualizado copilot-instructions.md para novo fluxo
- Adicionada documentação de auditoria do servidor
- Sincronizado código de produção com repositório

Novo workflow:
- Trabalhamos diretamente em /root/webmoney (symlink para /var/www/webmoney)
- Mudanças PHP são instantâneas
- Mudanças React requerem 'npm run build'
- Commit após validação funcional
2025-12-19 11:45:32 +01:00

445 lines
16 KiB
PHP
Executable File

<?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';
}
}