user()->id; // Parâmetros de período (padrão: últimos 12 meses) $months = min((int) $request->get('months', 12), 24); // máximo 24 meses $endDate = Carbon::parse($request->get('end_date', now()->endOfMonth())); $startDate = $endDate->copy()->subMonths($months - 1)->startOfMonth(); // Buscar dados mensais de transações completed (excluindo transferências) // Usar strftime para SQLite ou DATE_FORMAT para MySQL $driver = DB::connection()->getDriverName(); $monthExpression = $driver === 'sqlite' ? "strftime('%Y-%m', effective_date)" : "DATE_FORMAT(effective_date, '%Y-%m')"; $monthlyData = Transaction::ofUser($userId) ->completed() ->where('is_transfer', false) // Ignorar transferências entre contas ->whereBetween('effective_date', [$startDate, $endDate]) ->select( DB::raw("$monthExpression as month"), DB::raw("SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income"), DB::raw("SUM(CASE WHEN type = 'debit' THEN amount ELSE 0 END) as expense") ) ->groupBy('month') ->orderBy('month') ->get() ->keyBy('month'); // Criar array com todos os meses (mesmo sem dados) $result = []; $current = $startDate->copy(); $cumulativeBalance = 0; // Calcular saldo inicial (antes do período) - excluindo transferências $initialBalance = Transaction::ofUser($userId) ->completed() ->where('is_transfer', false) ->where('effective_date', '<', $startDate) ->selectRaw("SUM(CASE WHEN type = 'credit' THEN amount ELSE -amount END) as balance") ->value('balance') ?? 0; $cumulativeBalance = (float) $initialBalance; while ($current <= $endDate) { $monthKey = $current->format('Y-m'); $data = $monthlyData->get($monthKey); $income = (float) ($data->income ?? 0); $expense = (float) ($data->expense ?? 0); $balance = $income - $expense; $cumulativeBalance += $balance; $result[] = [ 'month' => $monthKey, 'month_label' => $current->translatedFormat('M/Y'), 'income' => $income, 'expense' => $expense, 'balance' => $balance, 'cumulative_balance' => $cumulativeBalance, ]; $current->addMonth(); } // Totais do período $totals = [ 'income' => array_sum(array_column($result, 'income')), 'expense' => array_sum(array_column($result, 'expense')), 'balance' => array_sum(array_column($result, 'balance')), 'average_income' => count($result) > 0 ? array_sum(array_column($result, 'income')) / count($result) : 0, 'average_expense' => count($result) > 0 ? array_sum(array_column($result, 'expense')) / count($result) : 0, ]; return response()->json([ 'period' => [ 'start' => $startDate->format('Y-m-d'), 'end' => $endDate->format('Y-m-d'), 'months' => $months, ], 'data' => $result, 'totals' => $totals, ]); } /** * Resumo geral do dashboard * Ignora transações marcadas como transferências entre contas * Agrupa valores por divisa para sistema multi-divisa */ public function summary(Request $request): JsonResponse { $userId = $request->user()->id; // Saldo total das contas agrupado por divisa $balancesByCurrency = Account::where('user_id', $userId) ->where('is_active', true) ->where('include_in_total', true) ->select('currency', DB::raw('SUM(current_balance) as total')) ->groupBy('currency') ->get() ->mapWithKeys(function ($item) { return [$item->currency => (float) $item->total]; }) ->toArray(); // Transações do mês atual agrupadas por divisa da conta (excluindo transferências) $currentMonth = [ now()->startOfMonth()->format('Y-m-d'), now()->endOfMonth()->format('Y-m-d') ]; $monthlyStatsByCurrency = Transaction::where('transactions.user_id', $userId) ->whereIn('transactions.status', ['completed', 'effective']) ->where('transactions.is_transfer', false) ->whereBetween('transactions.effective_date', $currentMonth) ->whereNull('transactions.deleted_at') ->join('accounts', 'transactions.account_id', '=', 'accounts.id') ->select( 'accounts.currency', DB::raw("SUM(CASE WHEN transactions.type = 'credit' THEN transactions.amount ELSE 0 END) as income"), DB::raw("SUM(CASE WHEN transactions.type = 'debit' THEN transactions.amount ELSE 0 END) as expense"), DB::raw("COUNT(*) as transactions_count") ) ->groupBy('accounts.currency') ->get(); $incomeByCurrency = []; $expenseByCurrency = []; $transactionsCount = 0; foreach ($monthlyStatsByCurrency as $stats) { $incomeByCurrency[$stats->currency] = (float) $stats->income; $expenseByCurrency[$stats->currency] = (float) $stats->expense; $transactionsCount += (int) $stats->transactions_count; } // Pendentes agrupadas por divisa (excluindo transferências) $pendingByCurrency = Transaction::where('transactions.user_id', $userId) ->where('transactions.status', 'pending') ->where('transactions.is_transfer', false) ->whereNull('transactions.deleted_at') ->join('accounts', 'transactions.account_id', '=', 'accounts.id') ->select( 'accounts.currency', DB::raw("SUM(CASE WHEN transactions.type = 'credit' THEN transactions.planned_amount ELSE 0 END) as income"), DB::raw("SUM(CASE WHEN transactions.type = 'debit' THEN transactions.planned_amount ELSE 0 END) as expense"), DB::raw("COUNT(*) as count") ) ->groupBy('accounts.currency') ->get(); $pendingIncomeByCurrency = []; $pendingExpenseByCurrency = []; $pendingCount = 0; foreach ($pendingByCurrency as $pending) { $pendingIncomeByCurrency[$pending->currency] = (float) $pending->income; $pendingExpenseByCurrency[$pending->currency] = (float) $pending->expense; $pendingCount += (int) $pending->count; } // Atrasadas (vencidas) agrupadas por divisa - excluindo transferências $overdueByCurrency = Transaction::where('transactions.user_id', $userId) ->where('transactions.status', 'pending') ->where('transactions.is_transfer', false) ->where('transactions.planned_date', '<', now()->startOfDay()) ->whereNull('transactions.deleted_at') ->join('accounts', 'transactions.account_id', '=', 'accounts.id') ->select( 'accounts.currency', DB::raw("SUM(transactions.planned_amount) as total"), DB::raw("COUNT(*) as count") ) ->groupBy('accounts.currency') ->get(); $overdueTotalByCurrency = []; $overdueCount = 0; foreach ($overdueByCurrency as $overdue) { $overdueTotalByCurrency[$overdue->currency] = (float) $overdue->total; $overdueCount += (int) $overdue->count; } // Determinar divisa principal (a com maior saldo ou primeira encontrada) $primaryCurrency = !empty($balancesByCurrency) ? array_key_first($balancesByCurrency) : 'BRL'; return response()->json([ // Compatibilidade com versão anterior (usando primeira divisa) 'total_balance' => (float) ($balancesByCurrency[$primaryCurrency] ?? 0), 'current_month' => [ 'income' => (float) ($incomeByCurrency[$primaryCurrency] ?? 0), 'expense' => (float) ($expenseByCurrency[$primaryCurrency] ?? 0), 'balance' => (float) (($incomeByCurrency[$primaryCurrency] ?? 0) - ($expenseByCurrency[$primaryCurrency] ?? 0)), 'transactions_count' => $transactionsCount, ], 'pending' => [ 'income' => (float) ($pendingIncomeByCurrency[$primaryCurrency] ?? 0), 'expense' => (float) ($pendingExpenseByCurrency[$primaryCurrency] ?? 0), 'count' => $pendingCount, ], 'overdue' => [ 'total' => (float) ($overdueTotalByCurrency[$primaryCurrency] ?? 0), 'count' => $overdueCount, ], // Novos campos multi-divisa 'primary_currency' => $primaryCurrency, 'balances_by_currency' => $balancesByCurrency, 'current_month_by_currency' => [ 'income' => $incomeByCurrency, 'expense' => $expenseByCurrency, ], 'pending_by_currency' => [ 'income' => $pendingIncomeByCurrency, 'expense' => $pendingExpenseByCurrency, ], 'overdue_by_currency' => $overdueTotalByCurrency, ]); } /** * Despesas por categoria (últimos N meses) * Ignora transações marcadas como transferências entre contas */ public function expensesByCategory(Request $request): JsonResponse { $userId = $request->user()->id; $months = min((int) $request->get('months', 3), 12); $startDate = now()->subMonths($months - 1)->startOfMonth(); $endDate = now()->endOfMonth(); $data = Transaction::ofUser($userId) ->completed() ->where('is_transfer', false) // Ignorar transferências entre contas ->where('type', 'debit') ->whereBetween('effective_date', [$startDate, $endDate]) ->whereNotNull('category_id') ->select( 'category_id', DB::raw('SUM(amount) as total'), DB::raw('COUNT(*) as count') ) ->groupBy('category_id') ->with('category:id,name,color,icon') ->orderByDesc('total') ->limit(10) ->get(); $total = $data->sum('total'); $result = $data->map(function ($item) use ($total) { return [ 'category_id' => $item->category_id, 'category_name' => $item->category->name ?? 'Sem categoria', 'category_color' => $item->category->color ?? '#6b7280', 'category_icon' => $item->category->icon ?? 'bi-tag', 'total' => (float) $item->total, 'count' => (int) $item->count, 'percentage' => $total > 0 ? round(($item->total / $total) * 100, 1) : 0, ]; }); return response()->json([ 'period' => [ 'start' => $startDate->format('Y-m-d'), 'end' => $endDate->format('Y-m-d'), 'months' => $months, ], 'data' => $result, 'total' => (float) $total, ]); } /** * Receitas por categoria (últimos N meses) * Ignora transações marcadas como transferências entre contas */ public function incomeByCategory(Request $request): JsonResponse { $userId = $request->user()->id; $months = min((int) $request->get('months', 3), 12); $startDate = now()->subMonths($months - 1)->startOfMonth(); $endDate = now()->endOfMonth(); $data = Transaction::ofUser($userId) ->completed() ->where('is_transfer', false) // Ignorar transferências entre contas ->where('type', 'credit') ->whereBetween('effective_date', [$startDate, $endDate]) ->whereNotNull('category_id') ->select( 'category_id', DB::raw('SUM(amount) as total'), DB::raw('COUNT(*) as count') ) ->groupBy('category_id') ->with('category:id,name,color,icon') ->orderByDesc('total') ->limit(10) ->get(); $total = $data->sum('total'); $result = $data->map(function ($item) use ($total) { return [ 'category_id' => $item->category_id, 'category_name' => $item->category->name ?? 'Sem categoria', 'category_color' => $item->category->color ?? '#6b7280', 'category_icon' => $item->category->icon ?? 'bi-tag', 'total' => (float) $item->total, 'count' => (int) $item->count, 'percentage' => $total > 0 ? round(($item->total / $total) * 100, 1) : 0, ]; }); return response()->json([ 'period' => [ 'start' => $startDate->format('Y-m-d'), 'end' => $endDate->format('Y-m-d'), 'months' => $months, ], 'data' => $result, 'total' => (float) $total, ]); } /** * Análise de diferenças entre valores planejados e efetivos * Mostra sobrepagamentos e subpagamentos */ public function paymentVariances(Request $request): JsonResponse { $userId = $request->user()->id; $months = min((int) $request->get('months', 3), 12); $startDate = now()->subMonths($months - 1)->startOfMonth(); $endDate = now()->endOfMonth(); // Buscar transações completed com diferença entre planned_amount e amount $transactions = Transaction::ofUser($userId) ->completed() ->where('is_transfer', false) ->whereBetween('effective_date', [$startDate, $endDate]) ->whereRaw('ABS(amount - planned_amount) > 0.01') // Diferença maior que 1 centavo ->with(['category:id,name,color,icon', 'account:id,name']) ->orderByRaw('ABS(amount - planned_amount) DESC') ->limit(50) ->get(); $result = $transactions->map(function ($t) { $variance = $t->amount - $t->planned_amount; $variancePercent = $t->planned_amount > 0 ? round(($variance / $t->planned_amount) * 100, 2) : 0; // Calcular dias de atraso (diferença entre effective_date e planned_date) $delayDays = null; if ($t->planned_date && $t->effective_date) { $delayDays = $t->planned_date->diffInDays($t->effective_date, false); // Positivo = pago depois do planejado (atrasado) // Negativo = pago antes do planejado (adiantado) } return [ 'id' => $t->id, 'description' => $t->description, 'type' => $t->type, 'planned_amount' => (float) $t->planned_amount, 'actual_amount' => (float) $t->amount, 'variance' => (float) $variance, 'variance_percent' => $variancePercent, 'effective_date' => $t->effective_date->format('Y-m-d'), 'planned_date' => $t->planned_date ? $t->planned_date->format('Y-m-d') : null, 'delay_days' => $delayDays, 'category' => $t->category ? [ 'id' => $t->category->id, 'name' => $t->category->name, 'color' => $t->category->color, ] : null, 'account' => $t->account ? [ 'id' => $t->account->id, 'name' => $t->account->name, ] : null, ]; }); // Calcular totais $overpayments = $result->filter(fn($t) => $t['variance'] > 0); $underpayments = $result->filter(fn($t) => $t['variance'] < 0); // Agrupar por mês para o gráfico $byMonth = $transactions->groupBy(function ($t) { return $t->effective_date->format('Y-m'); })->map(function ($items, $month) { $over = $items->filter(fn($t) => $t->amount > $t->planned_amount) ->sum(fn($t) => $t->amount - $t->planned_amount); $under = $items->filter(fn($t) => $t->amount < $t->planned_amount) ->sum(fn($t) => $t->planned_amount - $t->amount); return [ 'month' => $month, 'overpayment' => round($over, 2), 'underpayment' => round($under, 2), 'net' => round($over - $under, 2), 'count' => $items->count(), ]; })->sortKeys()->values(); return response()->json([ 'period' => [ 'start' => $startDate->format('Y-m-d'), 'end' => $endDate->format('Y-m-d'), 'months' => $months, ], 'summary' => [ 'total_overpayment' => round($overpayments->sum('variance'), 2), 'total_underpayment' => round(abs($underpayments->sum('variance')), 2), 'net_variance' => round($result->sum('variance'), 2), 'overpayment_count' => $overpayments->count(), 'underpayment_count' => $underpayments->count(), ], 'by_month' => $byMonth, 'transactions' => $result, ]); } /** * Dados do calendário para o dashboard * Retorna transações e instâncias recorrentes pendentes por data */ public function calendar(Request $request): JsonResponse { $userId = $request->user()->id; // Período: mês atual por padrão, ou o mês especificado $year = (int) $request->get('year', now()->year); $month = (int) $request->get('month', now()->month); $startDate = Carbon::create($year, $month, 1)->startOfMonth(); $endDate = $startDate->copy()->endOfMonth(); // Buscar transações do período // Usar planned_date para todas as transações (funciona para efetivadas e pendentes) $transactions = Transaction::ofUser($userId) ->whereBetween('planned_date', [$startDate, $endDate]) ->with(['account:id,name,currency', 'category:id,name,color,icon']) ->orderBy('planned_date') ->get() ->map(function ($t) { $date = $t->effective_date ?? $t->planned_date; return [ 'id' => $t->id, 'type' => 'transaction', 'date' => $date->format('Y-m-d'), 'description' => $t->description, 'amount' => (float) ($t->amount ?? $t->planned_amount), 'transaction_type' => $t->type, 'status' => $t->status, 'is_transfer' => $t->is_transfer, 'account' => $t->account ? [ 'id' => $t->account->id, 'name' => $t->account->name, 'currency' => $t->account->currency, ] : null, 'category' => $t->category ? [ 'id' => $t->category->id, 'name' => $t->category->name, 'color' => $t->category->color, 'icon' => $t->category->icon, ] : null, ]; }); // Buscar instâncias recorrentes pendentes do período $recurringInstances = RecurringInstance::where('user_id', $userId) ->whereBetween('due_date', [$startDate, $endDate]) ->where('status', 'pending') ->whereNull('transaction_id') // Não reconciliadas ->with(['template:id,name,type,planned_amount,account_id,category_id', 'template.account:id,name,currency', 'template.category:id,name,color,icon']) ->orderBy('due_date') ->get() ->map(function ($ri) { return [ 'id' => $ri->id, 'type' => 'recurring', 'date' => $ri->due_date->format('Y-m-d'), 'description' => $ri->template->name ?? 'Recorrência', 'amount' => (float) $ri->planned_amount, 'transaction_type' => $ri->template->type ?? 'debit', 'status' => $ri->status, 'occurrence_number' => $ri->occurrence_number, 'template_id' => $ri->recurring_template_id, 'account' => $ri->template && $ri->template->account ? [ 'id' => $ri->template->account->id, 'name' => $ri->template->account->name, 'currency' => $ri->template->account->currency, ] : null, 'category' => $ri->template && $ri->template->category ? [ 'id' => $ri->template->category->id, 'name' => $ri->template->category->name, 'color' => $ri->template->category->color, 'icon' => $ri->template->category->icon, ] : null, ]; }); // Combinar e agrupar por data $allItems = $transactions->concat($recurringInstances); $byDate = $allItems->groupBy('date')->map(function ($items, $date) { return [ 'date' => $date, 'items' => $items->values(), 'total_credit' => $items->where('transaction_type', 'credit')->sum('amount'), 'total_debit' => $items->where('transaction_type', 'debit')->sum('amount'), 'has_transactions' => $items->where('type', 'transaction')->count() > 0, 'has_recurring' => $items->where('type', 'recurring')->count() > 0, 'pending_count' => $items->whereIn('status', ['pending', 'scheduled'])->count(), ]; })->values(); // Resumo do mês (excluindo transferências entre contas) $nonTransferTransactions = $transactions->where('is_transfer', false); $summary = [ 'transactions_count' => $nonTransferTransactions->count(), 'recurring_count' => $recurringInstances->count(), 'total_income' => $nonTransferTransactions->where('transaction_type', 'credit')->sum('amount'), 'total_expense' => $nonTransferTransactions->where('transaction_type', 'debit')->sum('amount'), 'pending_recurring' => $recurringInstances->count(), 'pending_recurring_amount' => $recurringInstances->sum('amount'), ]; return response()->json([ 'period' => [ 'year' => $year, 'month' => $month, 'start' => $startDate->format('Y-m-d'), 'end' => $endDate->format('Y-m-d'), ], 'by_date' => $byDate, 'summary' => $summary, ]); } /** * Transações e recorrências de um dia específico */ public function calendarDay(Request $request): JsonResponse { $userId = $request->user()->id; $date = Carbon::parse($request->get('date', now()->format('Y-m-d'))); // Buscar transações do dia (usar planned_date para incluir pendentes) $transactions = Transaction::ofUser($userId) ->whereDate('planned_date', $date) ->with(['account:id,name,currency', 'category:id,name,color,icon']) ->orderBy('planned_date') ->orderBy('created_at') ->get() ->map(function ($t) { $txDate = $t->effective_date ?? $t->planned_date; return [ 'id' => $t->id, 'type' => 'transaction', 'date' => $txDate->format('Y-m-d'), 'description' => $t->description, 'amount' => (float) ($t->amount ?? $t->planned_amount), 'transaction_type' => $t->type, 'status' => $t->status, 'is_transfer' => $t->is_transfer, 'notes' => $t->notes, 'account' => $t->account ? [ 'id' => $t->account->id, 'name' => $t->account->name, 'currency' => $t->account->currency, ] : null, 'category' => $t->category ? [ 'id' => $t->category->id, 'name' => $t->category->name, 'color' => $t->category->color, 'icon' => $t->category->icon, ] : null, ]; }); // Buscar instâncias recorrentes pendentes do dia $recurringInstances = RecurringInstance::where('user_id', $userId) ->whereDate('due_date', $date) ->where('status', 'pending') ->whereNull('transaction_id') ->with(['template:id,name,type,planned_amount,account_id,category_id,description,transaction_description', 'template.account:id,name,currency', 'template.category:id,name,color,icon']) ->orderBy('due_date') ->get() ->map(function ($ri) { return [ 'id' => $ri->id, 'type' => 'recurring', 'date' => $ri->due_date->format('Y-m-d'), 'description' => $ri->template->name ?? 'Recorrência', 'amount' => (float) $ri->planned_amount, 'transaction_type' => $ri->template->type ?? 'debit', 'status' => $ri->status, 'occurrence_number' => $ri->occurrence_number, 'template_id' => $ri->recurring_template_id, 'notes' => $ri->template->description ?? null, 'account' => $ri->template && $ri->template->account ? [ 'id' => $ri->template->account->id, 'name' => $ri->template->account->name, 'currency' => $ri->template->account->currency, ] : null, 'category' => $ri->template && $ri->template->category ? [ 'id' => $ri->template->category->id, 'name' => $ri->template->category->name, 'color' => $ri->template->category->color, 'icon' => $ri->template->category->icon, ] : null, ]; }); // Combinar $allItems = $transactions->concat($recurringInstances); // Para o resumo, excluir transferências entre contas $nonTransferItems = $allItems->filter(fn($item) => !($item['is_transfer'] ?? false)); return response()->json([ 'date' => $date->format('Y-m-d'), 'date_formatted' => $date->translatedFormat('l, d F Y'), 'items' => $allItems->values(), 'summary' => [ 'transactions_count' => $transactions->count(), 'recurring_count' => $recurringInstances->count(), 'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'), 'total_debit' => $nonTransferItems->where('transaction_type', 'debit')->sum('amount'), ], ]); } /** * Transações pendentes dos próximos dias (incluindo hoje) */ public function upcomingTransactions(Request $request): JsonResponse { $userId = $request->user()->id; $days = min((int) $request->get('days', 7), 30); // máximo 30 dias $startDate = now()->startOfDay(); $endDate = now()->addDays($days - 1)->endOfDay(); // Buscar transações pendentes do período // Para pendentes: usar planned_date (effective_date é NULL) $transactions = Transaction::ofUser($userId) ->whereIn('status', ['pending', 'scheduled']) ->whereBetween('planned_date', [$startDate, $endDate]) ->with(['account:id,name,currency', 'category:id,name,color,icon']) ->orderBy('planned_date') ->orderBy('created_at') ->get() ->map(function ($t) { $date = $t->effective_date ?? $t->planned_date; return [ 'id' => $t->id, 'type' => 'transaction', 'date' => $date->format('Y-m-d'), 'date_formatted' => $date->translatedFormat('D, d M'), 'description' => $t->description, 'amount' => (float) ($t->amount ?? $t->planned_amount), 'currency' => $t->account->currency ?? 'EUR', 'transaction_type' => $t->type, 'status' => $t->status, 'is_transfer' => $t->is_transfer, 'days_until' => (int) now()->startOfDay()->diffInDays($date, false), 'account' => $t->account ? [ 'id' => $t->account->id, 'name' => $t->account->name, 'currency' => $t->account->currency, ] : null, 'category' => $t->category ? [ 'id' => $t->category->id, 'name' => $t->category->name, 'color' => $t->category->color, 'icon' => $t->category->icon, ] : null, ]; }); // Buscar instâncias recorrentes pendentes do período $recurringInstances = RecurringInstance::where('user_id', $userId) ->where('status', 'pending') ->whereNull('transaction_id') ->whereBetween('due_date', [$startDate, $endDate]) ->with(['template:id,name,type,planned_amount,account_id,category_id', 'template.account:id,name,currency', 'template.category:id,name,color,icon']) ->orderBy('due_date') ->get() ->map(function ($ri) { return [ 'id' => $ri->id, 'type' => 'recurring', 'date' => $ri->due_date->format('Y-m-d'), 'date_formatted' => $ri->due_date->translatedFormat('D, d M'), 'description' => $ri->template->name ?? 'Recorrência', 'amount' => (float) $ri->planned_amount, 'currency' => $ri->template->account->currency ?? 'EUR', 'transaction_type' => $ri->template->type ?? 'debit', 'status' => $ri->status, 'occurrence_number' => $ri->occurrence_number, 'template_id' => $ri->recurring_template_id, 'days_until' => (int) now()->startOfDay()->diffInDays($ri->due_date, false), 'account' => $ri->template && $ri->template->account ? [ 'id' => $ri->template->account->id, 'name' => $ri->template->account->name, 'currency' => $ri->template->account->currency, ] : null, 'category' => $ri->template && $ri->template->category ? [ 'id' => $ri->template->category->id, 'name' => $ri->template->category->name, 'color' => $ri->template->category->color, 'icon' => $ri->template->category->icon, ] : null, ]; }); // Combinar e ordenar por data $allItems = $transactions->concat($recurringInstances) ->sortBy('date') ->values(); // Agrupar por data $byDate = $allItems->groupBy('date')->map(function ($items, $date) { $carbonDate = Carbon::parse($date); $daysUntil = (int) now()->startOfDay()->diffInDays($carbonDate, false); return [ 'date' => $date, 'date_formatted' => $carbonDate->translatedFormat('l, d M'), 'days_until' => $daysUntil, 'is_today' => $daysUntil === 0, 'items' => $items->values(), 'total_credit' => $items->where('transaction_type', 'credit')->where('is_transfer', '!==', true)->sum('amount'), 'total_debit' => $items->where('transaction_type', 'debit')->where('is_transfer', '!==', true)->sum('amount'), ]; })->values(); // Totais gerais (excluindo transferências) $nonTransferItems = $allItems->filter(fn($item) => !($item['is_transfer'] ?? false)); $summary = [ 'total_items' => $allItems->count(), 'transactions_count' => $transactions->count(), 'recurring_count' => $recurringInstances->count(), 'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'), 'total_debit' => $nonTransferItems->where('transaction_type', 'debit')->sum('amount'), 'credit_count' => $nonTransferItems->where('transaction_type', 'credit')->count(), 'debit_count' => $nonTransferItems->where('transaction_type', 'debit')->count(), ]; return response()->json([ 'period' => [ 'start' => $startDate->format('Y-m-d'), 'end' => $endDate->format('Y-m-d'), 'days' => $days, ], 'by_date' => $byDate, 'items' => $allItems, 'summary' => $summary, ]); } /** * Transações em atraso (vencidas e não pagas) */ public function overdueTransactions(Request $request): JsonResponse { $userId = $request->user()->id; $limit = min((int) $request->get('limit', 50), 100); // máximo 100 $today = now()->startOfDay(); // Buscar transações pendentes com data planejada no passado $transactions = Transaction::ofUser($userId) ->whereIn('status', ['pending', 'scheduled']) ->where('is_transfer', false) ->where('planned_date', '<', $today) ->with(['account:id,name,currency', 'category:id,name,color,icon']) ->orderBy('planned_date') ->limit($limit) ->get() ->map(function ($t) use ($today) { $plannedDate = Carbon::parse($t->planned_date); $daysOverdue = (int) $plannedDate->diffInDays($today); return [ 'id' => $t->id, 'type' => 'transaction', 'planned_date' => $t->planned_date->format('Y-m-d'), 'planned_date_formatted' => $t->planned_date->translatedFormat('D, d M Y'), 'description' => $t->description, 'amount' => (float) ($t->planned_amount ?? $t->amount), 'currency' => $t->account->currency ?? 'EUR', 'transaction_type' => $t->type, 'status' => $t->status, 'days_overdue' => $daysOverdue, 'account' => $t->account ? [ 'id' => $t->account->id, 'name' => $t->account->name, 'currency' => $t->account->currency, ] : null, 'category' => $t->category ? [ 'id' => $t->category->id, 'name' => $t->category->name, 'color' => $t->category->color, 'icon' => $t->category->icon, ] : null, ]; }); // Buscar instâncias recorrentes em atraso $recurringInstances = RecurringInstance::where('user_id', $userId) ->where('status', 'pending') ->whereNull('transaction_id') ->where('due_date', '<', $today) ->with(['template:id,name,type,planned_amount,account_id,category_id', 'template.account:id,name,currency', 'template.category:id,name,color,icon']) ->orderBy('due_date') ->limit($limit) ->get() ->map(function ($ri) use ($today) { $dueDate = Carbon::parse($ri->due_date); $daysOverdue = (int) $dueDate->diffInDays($today); return [ 'id' => $ri->id, 'type' => 'recurring', 'planned_date' => $ri->due_date->format('Y-m-d'), 'planned_date_formatted' => $ri->due_date->translatedFormat('D, d M Y'), 'description' => $ri->template->name ?? 'Recorrência', 'amount' => (float) $ri->planned_amount, 'currency' => $ri->template->account->currency ?? 'EUR', 'transaction_type' => $ri->template->type ?? 'debit', 'status' => $ri->status, 'occurrence_number' => $ri->occurrence_number, 'template_id' => $ri->recurring_template_id, 'days_overdue' => $daysOverdue, 'account' => $ri->template && $ri->template->account ? [ 'id' => $ri->template->account->id, 'name' => $ri->template->account->name, 'currency' => $ri->template->account->currency, ] : null, 'category' => $ri->template && $ri->template->category ? [ 'id' => $ri->template->category->id, 'name' => $ri->template->category->name, 'color' => $ri->template->category->color, 'icon' => $ri->template->category->icon, ] : null, ]; }); // Buscar parcelas de passivo em atraso $liabilityInstallments = LiabilityInstallment::whereHas('liabilityAccount', function ($query) use ($userId) { $query->where('user_id', $userId); }) ->where('status', 'pending') ->where('due_date', '<', $today) ->with(['liabilityAccount:id,name,creditor,currency']) ->orderBy('due_date') ->limit($limit) ->get() ->map(function ($li) use ($today) { $dueDate = Carbon::parse($li->due_date); $daysOverdue = (int) $dueDate->diffInDays($today); return [ 'id' => $li->id, 'type' => 'liability', 'planned_date' => $li->due_date->format('Y-m-d'), 'planned_date_formatted' => $li->due_date->translatedFormat('D, d M Y'), 'description' => $li->liabilityAccount->name . ' - Parcela ' . $li->installment_number, 'amount' => (float) $li->installment_amount, 'currency' => $li->liabilityAccount->currency ?? 'EUR', 'transaction_type' => 'debit', 'status' => $li->status, 'installment_number' => $li->installment_number, 'liability_account_id' => $li->liability_account_id, 'creditor' => $li->liabilityAccount->creditor, 'days_overdue' => $daysOverdue, 'account' => null, 'category' => null, ]; }); // Combinar e ordenar por dias em atraso (mais antigo primeiro) $allItems = $transactions->concat($recurringInstances)->concat($liabilityInstallments) ->sortByDesc('days_overdue') ->values(); // Agrupar por faixa de atraso $byRange = collect([ ['key' => 'critical', 'min' => 30, 'max' => PHP_INT_MAX, 'label' => '> 30 dias'], ['key' => 'high', 'min' => 15, 'max' => 29, 'label' => '15-30 dias'], ['key' => 'medium', 'min' => 7, 'max' => 14, 'label' => '7-14 dias'], ['key' => 'low', 'min' => 1, 'max' => 6, 'label' => '1-6 dias'], ])->map(function ($range) use ($allItems) { $items = $allItems->filter(function ($item) use ($range) { return $item['days_overdue'] >= $range['min'] && $item['days_overdue'] <= $range['max']; })->values(); return [ 'key' => $range['key'], 'label' => $range['label'], 'min_days' => $range['min'], 'max_days' => $range['max'] === PHP_INT_MAX ? null : $range['max'], 'items' => $items, 'count' => $items->count(), 'total' => $items->sum('amount'), ]; })->filter(fn($range) => $range['count'] > 0)->values(); // Totais gerais $summary = [ 'total_items' => $allItems->count(), 'transactions_count' => $transactions->count(), 'recurring_count' => $recurringInstances->count(), 'liability_count' => $liabilityInstallments->count(), 'total_amount' => $allItems->sum('amount'), 'total_credit' => $allItems->where('transaction_type', 'credit')->sum('amount'), 'total_debit' => $allItems->where('transaction_type', 'debit')->sum('amount'), 'oldest_date' => $allItems->isNotEmpty() ? $allItems->first()['planned_date'] : null, 'max_days_overdue' => $allItems->isNotEmpty() ? $allItems->first()['days_overdue'] : 0, ]; return response()->json([ 'by_range' => $byRange, 'items' => $allItems->take($limit), 'summary' => $summary, ]); } }