userId = Auth::id(); $this->setPrimaryCurrency(); $this->loadExchangeRates(); } private function setPrimaryCurrency() { $account = Account::where('user_id', $this->userId) ->where('include_in_total', true) ->orderByDesc('current_balance') ->first(); $this->primaryCurrency = $account->currency ?? 'EUR'; } private function loadExchangeRates() { $this->exchangeRates = [ 'EUR' => 1.0, 'USD' => 0.92, 'GBP' => 1.17, 'BRL' => 0.18, 'MXN' => 0.054, 'COP' => 0.00023, 'ARS' => 0.0011, 'CLP' => 0.0010, 'PEN' => 0.25, ]; } private function convertToPrimaryCurrency($amount, $fromCurrency) { if ($fromCurrency === $this->primaryCurrency) return $amount; $rate = $this->exchangeRates[$fromCurrency] ?? 1; $primaryRate = $this->exchangeRates[$this->primaryCurrency] ?? 1; return ($amount * $rate) / $primaryRate; } // Condición base para excluir transferencias private function excludeTransfers() { return "(is_transfer IS NULL OR is_transfer = 0)"; } /** * Resumen general de reportes con multi-divisa */ public function summary(Request $request) { $this->init(); $year = $request->get('year', now()->year); // Datos por moneda $yearData = DB::select(" SELECT COALESCE(a.currency, 'EUR') as currency, SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income, SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND YEAR(t.effective_date) = ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY COALESCE(a.currency, 'EUR') ", [$this->userId, $year]); $lastYearData = DB::select(" SELECT COALESCE(a.currency, 'EUR') as currency, SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income, SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND YEAR(t.effective_date) = ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY COALESCE(a.currency, 'EUR') ", [$this->userId, $year - 1]); // Convertir y sumar $currentIncome = 0; $currentExpense = 0; $byCurrency = []; foreach ($yearData as $row) { $currentIncome += $this->convertToPrimaryCurrency($row->income, $row->currency); $currentExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency); $byCurrency[$row->currency] = [ 'income' => round($row->income, 2), 'expense' => round($row->expense, 2), 'balance' => round($row->income - $row->expense, 2), ]; } $previousIncome = 0; $previousExpense = 0; foreach ($lastYearData as $row) { $previousIncome += $this->convertToPrimaryCurrency($row->income, $row->currency); $previousExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency); } return response()->json([ 'year' => $year, 'currency' => $this->primaryCurrency, 'current' => [ 'income' => round($currentIncome, 2), 'expense' => round($currentExpense, 2), 'balance' => round($currentIncome - $currentExpense, 2), ], 'previous' => [ 'income' => round($previousIncome, 2), 'expense' => round($previousExpense, 2), 'balance' => round($previousIncome - $previousExpense, 2), ], 'variation' => [ 'income' => $previousIncome > 0 ? round((($currentIncome - $previousIncome) / $previousIncome) * 100, 1) : 0, 'expense' => $previousExpense > 0 ? round((($currentExpense - $previousExpense) / $previousExpense) * 100, 1) : 0, ], 'by_currency' => $byCurrency, ]); } /** * Reporte por categorías con multi-divisa */ public function byCategory(Request $request) { $this->init(); $startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d')); $endDate = $request->get('end_date', now()->format('Y-m-d')); $type = $request->get('type', 'debit'); $groupByParent = $request->get('group_by_parent', false); $parentId = $request->get('parent_id'); // Se filtrar por parent_id, mostra subcategorias dessa categoria pai if ($parentId) { $data = DB::select(" SELECT c.id as category_id, c.name as category_name, c.icon, c.color, COALESCE(a.currency, 'EUR') as currency, SUM(ABS(t.amount)) as total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.effective_date BETWEEN ? AND ? AND t.type = ? AND (c.id = ? OR c.parent_id = ?) AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY c.id, c.name, c.icon, c.color, COALESCE(a.currency, 'EUR') ORDER BY total DESC ", [$this->userId, $startDate, $endDate, $type, $parentId, $parentId]); } // Si se quiere agrupar por categoría padre, obtenemos el nombre del padre else if ($groupByParent) { $data = DB::select(" SELECT COALESCE(c.parent_id, c.id) as category_id, COALESCE(cp.name, c.name) as category_name, COALESCE(cp.icon, c.icon) as icon, COALESCE(cp.color, c.color) as color, COALESCE(a.currency, 'EUR') as currency, SUM(ABS(t.amount)) as total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN categories cp ON c.parent_id = cp.id LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.effective_date BETWEEN ? AND ? AND t.type = ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY COALESCE(c.parent_id, c.id), COALESCE(cp.name, c.name), COALESCE(cp.icon, c.icon), COALESCE(cp.color, c.color), COALESCE(a.currency, 'EUR') ORDER BY total DESC ", [$this->userId, $startDate, $endDate, $type]); } else { // Vista padrão: agrupar por categoria pai (soma transações de subcategorias) $data = DB::select(" SELECT COALESCE(c.parent_id, c.id) as category_id, COALESCE(cp.name, c.name) as category_name, COALESCE(cp.icon, c.icon) as icon, COALESCE(cp.color, c.color) as color, COALESCE(a.currency, 'EUR') as currency, SUM(ABS(t.amount)) as total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN categories cp ON c.parent_id = cp.id LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.effective_date BETWEEN ? AND ? AND t.type = ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY COALESCE(c.parent_id, c.id), COALESCE(cp.name, c.name), COALESCE(cp.icon, c.icon), COALESCE(cp.color, c.color), COALESCE(a.currency, 'EUR') ORDER BY total DESC ", [$this->userId, $startDate, $endDate, $type]); } // Agrupar por categoría y convertir monedas $byCategory = []; foreach ($data as $row) { $catId = $row->category_id ?? 0; if (!isset($byCategory[$catId])) { $byCategory[$catId] = [ 'category_id' => $catId, 'category_name' => $row->category_name ?? 'Sin categoría', 'icon' => $row->icon ?? 'bi-tag', 'color' => $row->color ?? '#6b7280', 'total_converted' => 0, 'by_currency' => [], ]; } $byCategory[$catId]['by_currency'][$row->currency] = round($row->total, 2); $byCategory[$catId]['total_converted'] += $this->convertToPrimaryCurrency($row->total, $row->currency); } // Ordenar y calcular porcentajes usort($byCategory, fn($a, $b) => $b['total_converted'] <=> $a['total_converted']); $grandTotal = array_sum(array_column($byCategory, 'total_converted')); $result = array_map(function($cat) use ($grandTotal) { $cat['total'] = round($cat['total_converted'], 2); $cat['percentage'] = $grandTotal > 0 ? round(($cat['total_converted'] / $grandTotal) * 100, 1) : 0; unset($cat['total_converted']); return $cat; }, $byCategory); return response()->json([ 'data' => array_values($result), 'total' => round($grandTotal, 2), 'currency' => $this->primaryCurrency, 'period' => ['start' => $startDate, 'end' => $endDate], ]); } /** * Reporte por centro de costos (3 niveles: centro -> categoría -> subcategoría) */ public function byCostCenter(Request $request) { $this->init(); $startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d')); $endDate = $request->get('end_date', now()->format('Y-m-d')); $costCenterId = $request->get('cost_center_id'); $categoryId = $request->get('category_id'); // Nível 3: Subcategorias de uma categoria específica de um centro de custo if ($costCenterId !== null && $categoryId !== null) { $data = DB::select(" SELECT c.id as category_id, c.name as category_name, c.icon as category_icon, c.color as category_color, COALESCE(a.currency, 'EUR') as currency, SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income, SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense FROM transactions t LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.effective_date BETWEEN ? AND ? AND " . ($costCenterId == 0 ? "t.cost_center_id IS NULL" : "t.cost_center_id = ?") . " AND (c.id = ? OR c.parent_id = ?) AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY c.id, c.name, c.icon, c.color, COALESCE(a.currency, 'EUR') ORDER BY expense DESC ", $costCenterId == 0 ? [$this->userId, $startDate, $endDate, $categoryId, $categoryId] : [$this->userId, $startDate, $endDate, $costCenterId, $categoryId, $categoryId] ); } // Nível 2: Categorias pai de um centro de custo específico else if ($costCenterId !== null) { $data = DB::select(" SELECT COALESCE(c.parent_id, c.id) as category_id, COALESCE(cp.name, c.name) as category_name, COALESCE(cp.icon, c.icon) as category_icon, COALESCE(cp.color, c.color) as category_color, COALESCE(a.currency, 'EUR') as currency, SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income, SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense FROM transactions t LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN categories cp ON c.parent_id = cp.id LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.effective_date BETWEEN ? AND ? AND " . ($costCenterId == 0 ? "t.cost_center_id IS NULL" : "t.cost_center_id = ?") . " AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY COALESCE(c.parent_id, c.id), COALESCE(cp.name, c.name), COALESCE(cp.icon, c.icon), COALESCE(cp.color, c.color), COALESCE(a.currency, 'EUR') ORDER BY expense DESC ", $costCenterId == 0 ? [$this->userId, $startDate, $endDate] : [$this->userId, $startDate, $endDate, $costCenterId] ); } // Nível 1: Centros de custo else { $data = DB::select(" SELECT COALESCE(cc.id, 0) as cost_center_id, COALESCE(cc.name, 'Sem classificar') as cost_center_name, COALESCE(cc.color, '#6b7280') as color, COALESCE(a.currency, 'EUR') as currency, SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income, SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense FROM transactions t LEFT JOIN cost_centers cc ON t.cost_center_id = cc.id LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.effective_date BETWEEN ? AND ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY COALESCE(cc.id, 0), COALESCE(cc.name, 'Sem classificar'), COALESCE(cc.color, '#6b7280'), COALESCE(a.currency, 'EUR') ORDER BY expense DESC ", [$this->userId, $startDate, $endDate]); } // Agrupar y procesar resultados $results = []; // Nível 3: Subcategorias if ($costCenterId !== null && $categoryId !== null) { foreach ($data as $row) { $id = $row->category_id ?? 0; if (!isset($results[$id])) { $results[$id] = [ 'id' => $id == 0 ? null : $id, 'name' => $row->category_name ?? 'Sin categoría', 'icon' => $row->category_icon ?? 'bi-tag', 'color' => $row->category_color ?? '#6b7280', 'income_converted' => 0, 'expense_converted' => 0, ]; } $results[$id]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency); $results[$id]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency); } } // Nível 2: Categorias else if ($costCenterId !== null) { foreach ($data as $row) { $id = $row->category_id ?? 0; if (!isset($results[$id])) { $results[$id] = [ 'id' => $id == 0 ? null : $id, 'name' => $row->category_name ?? 'Sin categoría', 'icon' => $row->category_icon ?? 'bi-tag', 'color' => $row->category_color ?? '#6b7280', 'income_converted' => 0, 'expense_converted' => 0, ]; } $results[$id]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency); $results[$id]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency); } } // Nível 1: Centros de custo else { foreach ($data as $row) { $id = $row->cost_center_id; if (!isset($results[$id])) { $results[$id] = [ 'id' => $id == 0 ? null : $id, 'name' => $row->cost_center_name, 'color' => $row->color, 'income_converted' => 0, 'expense_converted' => 0, ]; } $results[$id]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency); $results[$id]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency); } } $result = array_map(function($item) { return [ 'id' => $item['id'], 'name' => $item['name'], 'icon' => $item['icon'] ?? null, 'color' => $item['color'], 'income' => round($item['income_converted'], 2), 'expense' => round($item['expense_converted'], 2), 'balance' => round($item['income_converted'] - $item['expense_converted'], 2), ]; }, $results); usort($result, fn($a, $b) => $b['expense'] <=> $a['expense']); return response()->json([ 'data' => array_values($result), 'currency' => $this->primaryCurrency, 'total_income' => round(array_sum(array_column($result, 'income')), 2), 'total_expense' => round(array_sum(array_column($result, 'expense')), 2), ]); } /** * Reporte de evolución mensual con multi-divisa */ public function monthlyEvolution(Request $request) { $this->init(); $months = $request->get('months', 12); $startDate = now()->subMonths($months)->startOfMonth()->format('Y-m-d'); $data = DB::select(" SELECT DATE_FORMAT(t.effective_date, '%Y-%m') as month, COALESCE(a.currency, 'EUR') as currency, SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income, SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.effective_date >= ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY DATE_FORMAT(t.effective_date, '%Y-%m'), COALESCE(a.currency, 'EUR') ORDER BY month ", [$this->userId, $startDate]); // Agrupar por mes y convertir $byMonth = []; foreach ($data as $row) { if (!isset($byMonth[$row->month])) { $byMonth[$row->month] = [ 'month' => $row->month, 'income_converted' => 0, 'expense_converted' => 0, 'by_currency' => [], ]; } $byMonth[$row->month]['by_currency'][$row->currency] = [ 'income' => round($row->income, 2), 'expense' => round($row->expense, 2), ]; $byMonth[$row->month]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency); $byMonth[$row->month]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency); } $result = array_map(function($item) { $balance = $item['income_converted'] - $item['expense_converted']; $savingsRate = $item['income_converted'] > 0 ? round(($balance / $item['income_converted']) * 100, 1) : 0; return [ 'month' => $item['month'], 'month_label' => Carbon::parse($item['month'] . '-01')->isoFormat('MMM YYYY'), 'income' => round($item['income_converted'], 2), 'expense' => round($item['expense_converted'], 2), 'balance' => round($balance, 2), 'savings_rate' => $savingsRate, 'by_currency' => $item['by_currency'], ]; }, $byMonth); $resultArray = array_values($result); $avgIncome = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'income')) / count($resultArray) : 0; $avgExpense = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'expense')) / count($resultArray) : 0; $avgBalance = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'balance')) / count($resultArray) : 0; $avgSavings = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'savings_rate')) / count($resultArray) : 0; return response()->json([ 'data' => $resultArray, 'currency' => $this->primaryCurrency, 'averages' => [ 'income' => round($avgIncome, 2), 'expense' => round($avgExpense, 2), 'balance' => round($avgBalance, 2), 'savings_rate' => round($avgSavings, 1), ], ]); } /** * Reporte de gastos por día de la semana con multi-divisa */ public function byDayOfWeek(Request $request) { $this->init(); $months = $request->get('months', 6); $startDate = now()->subMonths($months)->format('Y-m-d'); $data = DB::select(" SELECT DAYOFWEEK(t.effective_date) as day_num, COALESCE(a.currency, 'EUR') as currency, COUNT(*) as count, SUM(ABS(t.amount)) as total FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.type = 'debit' AND t.effective_date >= ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY DAYOFWEEK(t.effective_date), COALESCE(a.currency, 'EUR') ", [$this->userId, $startDate]); // Agrupar por día $byDay = []; for ($i = 1; $i <= 7; $i++) { $byDay[$i] = ['count' => 0, 'total_converted' => 0, 'by_currency' => []]; } foreach ($data as $row) { $byDay[$row->day_num]['count'] += $row->count; $byDay[$row->day_num]['total_converted'] += $this->convertToPrimaryCurrency($row->total, $row->currency); if (!isset($byDay[$row->day_num]['by_currency'][$row->currency])) { $byDay[$row->day_num]['by_currency'][$row->currency] = 0; } $byDay[$row->day_num]['by_currency'][$row->currency] += round($row->total, 2); } // Mapeo de días que el frontend traducirá $dayKeys = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; $result = []; foreach ($byDay as $dayNum => $info) { $result[] = [ 'day_key' => $dayKeys[$dayNum - 1], 'day_num' => $dayNum, 'count' => $info['count'], 'total' => round($info['total_converted'], 2), 'average' => $info['count'] > 0 ? round($info['total_converted'] / $info['count'], 2) : 0, 'by_currency' => $info['by_currency'], ]; } return response()->json([ 'data' => $result, 'currency' => $this->primaryCurrency, ]); } /** * Top gastos con multi-divisa */ public function topExpenses(Request $request) { $this->init(); $startDate = $request->get('start_date', now()->startOfMonth()->format('Y-m-d')); $endDate = $request->get('end_date', now()->format('Y-m-d')); $limit = $request->get('limit', 20); $data = DB::select(" SELECT t.id, t.description, ABS(t.amount) as amount, t.effective_date, COALESCE(a.currency, 'EUR') as currency, c.name as category_name, c.icon as category_icon, c.color as category_color, a.name as account_name, cc.name as cost_center_name FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN cost_centers cc ON t.cost_center_id = cc.id WHERE t.user_id = ? AND t.type = 'debit' AND t.effective_date BETWEEN ? AND ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} ORDER BY ABS(t.amount) DESC LIMIT ? ", [$this->userId, $startDate, $endDate, $limit]); $totalConverted = 0; $result = array_map(function($row) use (&$totalConverted) { $converted = $this->convertToPrimaryCurrency($row->amount, $row->currency); $totalConverted += $converted; return [ 'id' => $row->id, 'description' => $row->description, 'amount' => round($row->amount, 2), 'amount_converted' => round($converted, 2), 'currency' => $row->currency, 'date' => $row->effective_date, 'category' => $row->category_name, 'category_icon' => $row->category_icon, 'category_color' => $row->category_color, 'account' => $row->account_name, 'cost_center' => $row->cost_center_name, ]; }, $data); return response()->json([ 'data' => $result, 'total' => round($totalConverted, 2), 'currency' => $this->primaryCurrency, 'period' => ['start' => $startDate, 'end' => $endDate], ]); } /** * Comparativa de períodos con multi-divisa */ public function comparePeriods(Request $request) { $this->init(); $period1Start = $request->get('period1_start'); $period1End = $request->get('period1_end'); $period2Start = $request->get('period2_start'); $period2End = $request->get('period2_end'); if (!$period1Start) { $period1Start = now()->startOfMonth()->format('Y-m-d'); $period1End = now()->endOfMonth()->format('Y-m-d'); $period2Start = now()->subMonth()->startOfMonth()->format('Y-m-d'); $period2End = now()->subMonth()->endOfMonth()->format('Y-m-d'); } $getPeriodData = function($start, $end) { $data = DB::select(" SELECT COALESCE(a.currency, 'EUR') as currency, SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income, SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense, COUNT(*) as transactions FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.effective_date BETWEEN ? AND ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY COALESCE(a.currency, 'EUR') ", [$this->userId, $start, $end]); $income = 0; $expense = 0; $transactions = 0; $byCurrency = []; foreach ($data as $row) { $income += $this->convertToPrimaryCurrency($row->income, $row->currency); $expense += $this->convertToPrimaryCurrency($row->expense, $row->currency); $transactions += $row->transactions; $byCurrency[$row->currency] = [ 'income' => round($row->income, 2), 'expense' => round($row->expense, 2), ]; } return [ 'income' => round($income, 2), 'expense' => round($expense, 2), 'balance' => round($income - $expense, 2), 'transactions' => $transactions, 'by_currency' => $byCurrency, ]; }; $period1Data = $getPeriodData($period1Start, $period1End); $period2Data = $getPeriodData($period2Start, $period2End); $calcVariation = function($current, $previous) { return $previous > 0 ? round((($current - $previous) / $previous) * 100, 1) : 0; }; return response()->json([ 'period1' => array_merge([ 'label' => Carbon::parse($period1Start)->isoFormat('MMM YYYY'), 'start' => $period1Start, 'end' => $period1End, ], $period1Data), 'period2' => array_merge([ 'label' => Carbon::parse($period2Start)->isoFormat('MMM YYYY'), 'start' => $period2Start, 'end' => $period2End, ], $period2Data), 'variation' => [ 'income' => $calcVariation($period1Data['income'], $period2Data['income']), 'expense' => $calcVariation($period1Data['expense'], $period2Data['expense']), 'balance' => $calcVariation($period1Data['balance'], $period2Data['balance']), ], 'currency' => $this->primaryCurrency, ]); } /** * Reporte de cuentas con multi-divisa */ public function accountsReport(Request $request) { $this->init(); $accounts = Account::where('user_id', $this->userId)->get(); $result = []; $totalBalanceConverted = 0; foreach ($accounts as $account) { // Movimientos del mes (excluyendo transferencias) $monthStats = DB::select(" SELECT SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income, SUM(CASE WHEN type = 'debit' THEN ABS(amount) ELSE 0 END) as expense FROM transactions WHERE account_id = ? AND MONTH(effective_date) = ? AND YEAR(effective_date) = ? AND deleted_at IS NULL AND {$this->excludeTransfers()} ", [$account->id, now()->month, now()->year]); $stats = $monthStats[0] ?? (object)['income' => 0, 'expense' => 0]; // Últimas transacciones $recent = DB::select(" SELECT id, description, amount, type, effective_date FROM transactions WHERE account_id = ? AND deleted_at IS NULL ORDER BY effective_date DESC LIMIT 5 ", [$account->id]); $balanceConverted = $this->convertToPrimaryCurrency($account->current_balance, $account->currency); if ($account->include_in_total) { $totalBalanceConverted += $balanceConverted; } $result[] = [ 'id' => $account->id, 'name' => $account->name, 'type' => $account->type, 'currency' => $account->currency, 'balance' => round($account->current_balance, 2), 'balance_converted' => round($balanceConverted, 2), 'include_in_total' => $account->include_in_total, 'month_income' => round($stats->income ?? 0, 2), 'month_expense' => round($stats->expense ?? 0, 2), 'recent_activity' => array_map(function($t) { return [ 'id' => $t->id, 'description' => $t->description, 'amount' => $t->amount, 'type' => $t->type, 'date' => $t->effective_date, ]; }, $recent), ]; } return response()->json([ 'accounts' => $result, 'currency' => $this->primaryCurrency, 'summary' => [ 'total_accounts' => count($accounts), 'total_balance_converted' => round($totalBalanceConverted, 2), ], ]); } /** * Proyección de gastos con multi-divisa */ public function projection(Request $request) { $this->init(); $months = $request->get('months', 3); $startDate = now()->subMonths($months)->startOfMonth()->format('Y-m-d'); $endMonthStart = now()->startOfMonth()->format('Y-m-d'); $today = now()->format('Y-m-d'); $endOfMonth = now()->endOfMonth()->format('Y-m-d'); // ========================================================================= // 1. HISTÓRICO: Média mensal dos últimos N meses // ========================================================================= $historical = DB::select(" SELECT COALESCE(a.currency, 'EUR') as currency, SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) / ? as monthly_income, SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) / ? as monthly_expense FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.effective_date >= ? AND t.effective_date < ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY COALESCE(a.currency, 'EUR') ", [$months, $months, $this->userId, $startDate, $endMonthStart]); $histIncome = 0; $histExpense = 0; foreach ($historical as $row) { $histIncome += $this->convertToPrimaryCurrency($row->monthly_income, $row->currency); $histExpense += $this->convertToPrimaryCurrency($row->monthly_expense, $row->currency); } // ========================================================================= // 2. MÊS ATUAL: Transações já realizadas (effective_date) // ========================================================================= $current = DB::select(" SELECT COALESCE(a.currency, 'EUR') as currency, SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income, SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND MONTH(t.effective_date) = ? AND YEAR(t.effective_date) = ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY COALESCE(a.currency, 'EUR') ", [$this->userId, now()->month, now()->year]); $currIncome = 0; $currExpense = 0; foreach ($current as $row) { $currIncome += $this->convertToPrimaryCurrency($row->income, $row->currency); $currExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency); } $daysElapsed = max(1, now()->day); $daysInMonth = now()->daysInMonth; $daysRemaining = $daysInMonth - $daysElapsed; // ========================================================================= // 3. RECORRÊNCIAS PENDENTES: Até o fim do mês // ========================================================================= $recurringIncome = 0; $recurringExpense = 0; $recurrences = DB::select(" SELECT rt.id, rt.name, rt.planned_amount, rt.type, rt.frequency, rt.day_of_month, rt.start_date, rt.end_date, rt.last_generated_date, COALESCE(a.currency, 'EUR') as currency FROM recurring_templates rt LEFT JOIN accounts a ON rt.account_id = a.id WHERE rt.user_id = ? AND rt.is_active = 1 AND rt.deleted_at IS NULL AND (rt.end_date IS NULL OR rt.end_date >= ?) ", [$this->userId, $today]); foreach ($recurrences as $rec) { // Verificar se ainda vai executar este mês $nextDates = $this->getNextRecurrenceDates($rec, $today, $endOfMonth); foreach ($nextDates as $nextDate) { $amount = abs($rec->planned_amount); $converted = $this->convertToPrimaryCurrency($amount, $rec->currency); if ($rec->type === 'credit') { $recurringIncome += $converted; } else { $recurringExpense += $converted; } } } // ========================================================================= // 4. PASSIVOS PENDENTES: Parcelas até o fim do mês // ========================================================================= $liabilityExpense = 0; $pendingInstallments = DB::select(" SELECT li.installment_amount as amount, la.currency FROM liability_installments li JOIN liability_accounts la ON li.liability_account_id = la.id WHERE la.user_id = ? AND li.status = 'pending' AND li.due_date >= ? AND li.due_date <= ? AND li.deleted_at IS NULL ", [$this->userId, $today, $endOfMonth]); foreach ($pendingInstallments as $row) { $liabilityExpense += $this->convertToPrimaryCurrency(abs($row->amount), $row->currency); } // ========================================================================= // 5. TRANSAÇÕES EM ATRASO (overdue) // ========================================================================= $overdueIncome = 0; $overdueExpense = 0; // Transações pendentes vencidas (status='pending' e planned_date < hoje) $overduePendingTransactions = DB::select(" SELECT t.amount, t.type, COALESCE(a.currency, 'EUR') as currency FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.status = 'pending' AND t.planned_date < ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} ", [$this->userId, $today]); foreach ($overduePendingTransactions as $row) { $amount = abs($row->amount); $converted = $this->convertToPrimaryCurrency($amount, $row->currency); if ($row->type === 'credit') { $overdueIncome += $converted; } else { $overdueExpense += $converted; } } // Parcelas de passivos vencidas $overdueInstallments = DB::select(" SELECT li.installment_amount as amount, la.currency FROM liability_installments li JOIN liability_accounts la ON li.liability_account_id = la.id WHERE la.user_id = ? AND li.status != 'paid' AND li.due_date < ? AND li.deleted_at IS NULL ", [$this->userId, $today]); foreach ($overdueInstallments as $row) { $overdueExpense += $this->convertToPrimaryCurrency(abs($row->amount), $row->currency); } // Recorrências que deveriam ter executado mas não executaram $overdueRecurrences = $this->getOverdueRecurrences($today); foreach ($overdueRecurrences as $rec) { $overdueExpense += $this->convertToPrimaryCurrency($rec['amount'], $rec['currency']); } // Instâncias de recorrências pendentes vencidas $overdueRecurringInstances = DB::select(" SELECT ri.planned_amount as amount, rt.type, COALESCE(a.currency, 'EUR') as currency 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 ri.due_date < ? AND ri.deleted_at IS NULL ", [$this->userId, $today]); foreach ($overdueRecurringInstances as $row) { $amount = abs($row->amount); $converted = $this->convertToPrimaryCurrency($amount, $row->currency); if ($row->type === 'credit') { $overdueIncome += $converted; } else { $overdueExpense += $converted; } } // ========================================================================= // 6. TRANSAÇÕES PENDENTES ATÉ O FIM DO MÊS (planned_date entre hoje e fim do mês) // ========================================================================= $pendingIncome = 0; $pendingExpense = 0; $pendingTransactions = DB::select(" SELECT t.amount, t.type, COALESCE(a.currency, 'EUR') as currency FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.status = 'pending' AND t.planned_date >= ? AND t.planned_date <= ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} ", [$this->userId, $today, $endOfMonth]); foreach ($pendingTransactions as $row) { $amount = abs($row->amount); $converted = $this->convertToPrimaryCurrency($amount, $row->currency); if ($row->type === 'credit') { $pendingIncome += $converted; } else { $pendingExpense += $converted; } } // ========================================================================= // 7. CÁLCULOS FINAIS DA PROJEÇÃO // ========================================================================= // Projeção simples (extrapolação linear) $simpleProjectedExpense = ($currExpense / $daysElapsed) * $daysInMonth; $simpleProjectedIncome = ($currIncome / $daysElapsed) * $daysInMonth; // Projeção inteligente: realizado + pendente (todos os tipos) $smartProjectedIncome = $currIncome + $recurringIncome + $overdueIncome + $pendingIncome; $smartProjectedExpense = $currExpense + $recurringExpense + $liabilityExpense + $overdueExpense + $pendingExpense; return response()->json([ 'historical_average' => [ 'income' => round($histIncome, 2), 'expense' => round($histExpense, 2), 'balance' => round($histIncome - $histExpense, 2), ], 'current_month' => [ 'income' => round($currIncome, 2), 'expense' => round($currExpense, 2), 'balance' => round($currIncome - $currExpense, 2), 'days_elapsed' => $daysElapsed, 'days_remaining' => $daysRemaining, ], 'pending_this_month' => [ 'recurring_income' => round($recurringIncome, 2), 'recurring_expense' => round($recurringExpense, 2), 'liability_installments' => round($liabilityExpense, 2), 'pending_income' => round($pendingIncome, 2), 'pending_expense' => round($pendingExpense, 2), 'total_pending_income' => round($recurringIncome + $pendingIncome, 2), 'total_pending_expense' => round($recurringExpense + $liabilityExpense + $pendingExpense, 2), ], 'overdue' => [ 'income' => round($overdueIncome, 2), 'expense' => round($overdueExpense, 2), 'total' => round($overdueExpense - $overdueIncome, 2), ], 'projection' => [ // Valores principais (usa projeção inteligente) 'income' => round($smartProjectedIncome, 2), 'expense' => round($smartProjectedExpense, 2), 'balance' => round($smartProjectedIncome - $smartProjectedExpense, 2), // Projeção simples (extrapolação linear) 'simple' => [ 'income' => round($simpleProjectedIncome, 2), 'expense' => round($simpleProjectedExpense, 2), 'balance' => round($simpleProjectedIncome - $simpleProjectedExpense, 2), ], // Projeção inteligente (realizado + recorrências + passivos) 'smart' => [ 'income' => round($smartProjectedIncome, 2), 'expense' => round($smartProjectedExpense, 2), 'balance' => round($smartProjectedIncome - $smartProjectedExpense, 2), ], ], 'vs_average' => [ 'income' => $histIncome > 0 ? round((($smartProjectedIncome - $histIncome) / $histIncome) * 100, 1) : 0, 'expense' => $histExpense > 0 ? round((($smartProjectedExpense - $histExpense) / $histExpense) * 100, 1) : 0, ], 'currency' => $this->primaryCurrency, ]); } /** * Projeção de saldo para gráfico com dados diários/semanais */ public function projectionChart(Request $request) { $this->init(); $months = (int) min(max($request->input('months', 3), 1), 12); $today = Carbon::today(); $endDate = $today->copy()->addMonths($months); // Obter saldo atual total das contas (valor REAL de hoje) $currentBalance = DB::selectOne(" SELECT COALESCE(SUM(current_balance), 0) as total FROM accounts WHERE user_id = ? AND include_in_total = 1 AND deleted_at IS NULL ", [$this->userId])->total ?? 0; // Gerar pontos de dados (diário para até 3 meses, semanal para mais) $dataPoints = []; $runningBalance = (float) $currentBalance; $interval = $months <= 3 ? 'day' : 'week'; $current = $today->copy(); // ========================================================================= // BUSCAR INSTÂNCIAS DE RECORRÊNCIAS PENDENTES (incluindo VENCIDAS) // ========================================================================= $recurringInstances = DB::select(" SELECT ri.due_date as date, ri.planned_amount as amount, rt.type, COALESCE(a.currency, 'EUR') as currency, CASE WHEN ri.due_date < ? THEN 1 ELSE 0 END as is_overdue 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 ri.due_date <= ? AND ri.deleted_at IS NULL ORDER BY ri.due_date ", [$today->toDateString(), $this->userId, $endDate->toDateString()]); // ========================================================================= // BUSCAR TRANSAÇÕES PENDENTES/AGENDADAS (incluindo VENCIDAS) // ========================================================================= $scheduledTransactions = DB::select(" SELECT COALESCE(t.planned_date, t.effective_date) as date, t.amount, t.type, COALESCE(a.currency, 'EUR') as currency, CASE WHEN COALESCE(t.planned_date, t.effective_date) < ? THEN 1 ELSE 0 END as is_overdue FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.status IN ('pending', 'scheduled') AND COALESCE(t.planned_date, t.effective_date) <= ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} ORDER BY COALESCE(t.planned_date, t.effective_date) ", [$today->toDateString(), $this->userId, $endDate->toDateString()]); // ========================================================================= // BUSCAR PARCELAS DE PASSIVOS PENDENTES (incluindo VENCIDAS) // ========================================================================= $liabilityInstallments = DB::select(" SELECT li.due_date as date, li.installment_amount as amount, la.currency, CASE WHEN li.due_date < ? THEN 1 ELSE 0 END as is_overdue FROM liability_installments li JOIN liability_accounts la ON li.liability_account_id = la.id WHERE la.user_id = ? AND li.status = 'pending' AND li.due_date <= ? AND li.deleted_at IS NULL ORDER BY li.due_date ", [$today->toDateString(), $this->userId, $endDate->toDateString()]); // ========================================================================= // PROCESSAR VENCIDOS: aplicar no saldo inicial // ========================================================================= $overdueImpact = 0; foreach ($recurringInstances as $ri) { if ($ri->is_overdue) { $amount = $this->convertToPrimaryCurrency(abs($ri->amount), $ri->currency); if ($ri->type === 'credit') { $overdueImpact += $amount; } else { $overdueImpact -= $amount; } } } foreach ($scheduledTransactions as $tx) { if ($tx->is_overdue) { $amount = $this->convertToPrimaryCurrency(abs($tx->amount), $tx->currency); if ($tx->type === 'credit') { $overdueImpact += $amount; } else { $overdueImpact -= $amount; } } } foreach ($liabilityInstallments as $inst) { if ($inst->is_overdue) { $amount = $this->convertToPrimaryCurrency(abs($inst->amount), $inst->currency); $overdueImpact -= $amount; } } // Aplicar impacto dos vencidos ao saldo inicial $runningBalance += $overdueImpact; // ========================================================================= // PONTO INICIAL = SALDO ATUAL (sem modificações) // ========================================================================= $dataPoints[] = [ 'date' => $today->toDateString(), 'balance' => round($runningBalance, 2), 'label' => $today->format('d/m'), 'isToday' => true, ]; // ========================================================================= // GERAR PROJEÇÃO FUTURA // ========================================================================= while ($current->lt($endDate)) { if ($interval === 'day') { $current->addDay(); } else { $current->addWeek(); } if ($current->gt($endDate)) break; $periodStart = $dataPoints[count($dataPoints) - 1]['date']; $periodEnd = $current->toDateString(); // Processar instâncias de recorrências neste período (SOMENTE futuras, não vencidas) foreach ($recurringInstances as $ri) { if (!$ri->is_overdue && $ri->date > $periodStart && $ri->date <= $periodEnd) { $amount = $this->convertToPrimaryCurrency(abs($ri->amount), $ri->currency); if ($ri->type === 'credit') { $runningBalance += $amount; } else { $runningBalance -= $amount; } } } // Processar transações agendadas neste período (SOMENTE futuras, não vencidas) foreach ($scheduledTransactions as $tx) { if (!$tx->is_overdue && $tx->date > $periodStart && $tx->date <= $periodEnd) { $amount = $this->convertToPrimaryCurrency(abs($tx->amount), $tx->currency); if ($tx->type === 'credit') { $runningBalance += $amount; } else { $runningBalance -= $amount; } } } // Processar parcelas de passivos neste período (SOMENTE futuras, não vencidas) foreach ($liabilityInstallments as $inst) { if (!$inst->is_overdue && $inst->date > $periodStart && $inst->date <= $periodEnd) { $amount = $this->convertToPrimaryCurrency(abs($inst->amount), $inst->currency); $runningBalance -= $amount; } } $dataPoints[] = [ 'date' => $current->toDateString(), 'balance' => round($runningBalance, 2), 'label' => $current->format('d/m'), 'isToday' => false, ]; } // Calcular estatísticas $balances = array_column($dataPoints, 'balance'); $minBalance = min($balances); $maxBalance = max($balances); $avgBalance = array_sum($balances) / count($balances); $finalBalance = end($balances); // Detectar mês de saldo negativo (se houver) $negativeMonth = null; foreach ($dataPoints as $point) { if ($point['balance'] < 0) { $negativeMonth = Carbon::parse($point['date'])->format('M Y'); break; } } return response()->json([ 'data' => $dataPoints, 'summary' => [ 'current_balance' => round($currentBalance, 2), 'final_balance' => round($finalBalance, 2), 'min_balance' => round($minBalance, 2), 'max_balance' => round($maxBalance, 2), 'avg_balance' => round($avgBalance, 2), 'change' => round($finalBalance - $currentBalance, 2), 'change_percent' => $currentBalance != 0 ? round((($finalBalance - $currentBalance) / abs($currentBalance)) * 100, 1) : 0, 'negative_month' => $negativeMonth, 'overdue_impact' => round($overdueImpact, 2), ], 'period' => [ 'start' => $today->toDateString(), 'end' => $endDate->toDateString(), 'months' => $months, 'interval' => $interval, 'total_points' => count($dataPoints), ], 'currency' => $this->primaryCurrency, ]); } /** * Reporte de recurrencias con multi-divisa */ public function recurringReport(Request $request) { $this->init(); $templates = RecurringTemplate::where('user_id', $this->userId) ->where('is_active', true) ->with(['category', 'account']) ->get(); $monthlyIncomeConverted = 0; $monthlyExpenseConverted = 0; $byCurrency = []; $result = $templates->map(function($t) use (&$monthlyIncomeConverted, &$monthlyExpenseConverted, &$byCurrency) { $currency = $t->account ? $t->account->currency : 'EUR'; // CORRIGIDO: usar planned_amount em vez de amount $amount = abs($t->planned_amount ?? 0); // Converter para valor mensal baseado na frequência $monthlyAmount = $this->convertToMonthlyAmount($amount, $t->frequency); $converted = $this->convertToPrimaryCurrency($monthlyAmount, $currency); if (!isset($byCurrency[$currency])) { $byCurrency[$currency] = ['income' => 0, 'expense' => 0]; } if ($t->type === 'credit') { $monthlyIncomeConverted += $converted; $byCurrency[$currency]['income'] += $monthlyAmount; } else { $monthlyExpenseConverted += $converted; $byCurrency[$currency]['expense'] += $monthlyAmount; } return [ 'id' => $t->id, // CORRIGIDO: usar name em vez de description 'description' => $t->name ?? $t->transaction_description, 'amount' => round($amount, 2), 'monthly_amount' => round($monthlyAmount, 2), 'amount_converted' => round($converted, 2), 'currency' => $currency, 'type' => $t->type, 'frequency' => $t->frequency, 'category' => $t->category ? $t->category->name : null, 'category_icon' => $t->category ? $t->category->icon : null, 'category_color' => $t->category ? $t->category->color : null, 'next_date' => $t->next_occurrence_date, 'account' => $t->account ? $t->account->name : null, ]; }); return response()->json([ 'templates' => $result, 'currency' => $this->primaryCurrency, 'summary' => [ 'total_recurring' => $templates->count(), 'monthly_income' => round($monthlyIncomeConverted, 2), 'monthly_expense' => round($monthlyExpenseConverted, 2), 'net_recurring' => round($monthlyIncomeConverted - $monthlyExpenseConverted, 2), 'by_currency' => $byCurrency, ], ]); } /** * Converte um valor para equivalente mensal baseado na frequência */ private function convertToMonthlyAmount(float $amount, string $frequency): float { return match($frequency) { 'daily' => $amount * 30, 'weekly' => $amount * 4.33, 'biweekly' => $amount * 2.17, 'monthly' => $amount, 'bimonthly' => $amount / 2, 'quarterly' => $amount / 3, 'semiannual' => $amount / 6, 'annual' => $amount / 12, default => $amount, }; } /** * Reporte de pasivos/deudas */ public function liabilities(Request $request) { $this->init(); $liabilities = LiabilityAccount::where('user_id', $this->userId) ->with(['installments' => function($q) { $q->orderBy('due_date'); }]) ->get(); $totalDebtConverted = 0; $totalPaidConverted = 0; $totalPendingConverted = 0; $result = $liabilities->map(function($l) use (&$totalDebtConverted, &$totalPaidConverted, &$totalPendingConverted) { $currency = $l->currency ?? 'EUR'; $totalAmount = $l->total_contract_value ?? 0; // Usar installment_amount en lugar de amount $paidAmount = $l->installments->where('status', 'paid')->sum('installment_amount'); $pendingAmount = $l->installments->where('status', '!=', 'paid')->sum('installment_amount'); $totalDebtConverted += $this->convertToPrimaryCurrency($totalAmount, $currency); $totalPaidConverted += $this->convertToPrimaryCurrency($paidAmount, $currency); $totalPendingConverted += $this->convertToPrimaryCurrency($pendingAmount, $currency); // Próxima cuota pendiente $nextInstallment = $l->installments->where('status', '!=', 'paid')->sortBy('due_date')->first(); // Cuotas vencidas $overdueInstallments = $l->installments ->where('status', '!=', 'paid') ->where('due_date', '<', now()->format('Y-m-d')) ->count(); return [ 'id' => $l->id, 'name' => $l->name, 'type' => $l->type, 'currency' => $currency, 'total_amount' => round($totalAmount, 2), 'paid_amount' => round($paidAmount, 2), 'pending_amount' => round($pendingAmount, 2), 'progress' => $totalAmount > 0 ? round(($paidAmount / $totalAmount) * 100, 1) : 0, 'total_installments' => $l->installments->count(), 'paid_installments' => $l->installments->where('status', 'paid')->count(), 'overdue_installments' => $overdueInstallments, 'next_installment' => $nextInstallment ? [ 'amount' => round($nextInstallment->installment_amount, 2), 'due_date' => $nextInstallment->due_date, 'is_overdue' => $nextInstallment->due_date < now()->format('Y-m-d'), ] : null, 'start_date' => $l->start_date, 'end_date' => $l->end_date, ]; }); return response()->json([ 'data' => $result, 'currency' => $this->primaryCurrency, 'summary' => [ 'total_liabilities' => $liabilities->count(), 'total_debt' => round($totalDebtConverted, 2), 'total_paid' => round($totalPaidConverted, 2), 'total_pending' => round($totalPendingConverted, 2), 'progress' => $totalDebtConverted > 0 ? round(($totalPaidConverted / $totalDebtConverted) * 100, 1) : 0, ], ]); } /** * Transacciones futuras programadas * Incluye: transacciones pending/scheduled, cuotas de pasivos, y proyecciones de recurrencias */ public function futureTransactions(Request $request) { $this->init(); $days = (int) $request->get('days', 30); $endDate = now()->addDays($days)->format('Y-m-d'); $today = now()->format('Y-m-d'); $result = []; $totalIncomeConverted = 0; $totalExpenseConverted = 0; // 1. Transacciones pendientes/scheduled (usando planned_date) $pendingTransactions = DB::select(" SELECT t.id, t.description, COALESCE(t.planned_amount, t.amount) as amount, t.type, t.planned_date as date, t.status, COALESCE(a.currency, 'EUR') as currency, a.name as account_name, c.name as category_name, c.icon as category_icon, cc.name as cost_center_name FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN cost_centers cc ON t.cost_center_id = cc.id WHERE t.user_id = ? AND t.status IN ('pending', 'scheduled') AND t.planned_date >= ? AND t.planned_date <= ? AND t.deleted_at IS NULL ORDER BY t.planned_date ASC ", [$this->userId, $today, $endDate]); foreach ($pendingTransactions as $row) { $amount = abs($row->amount); $converted = $this->convertToPrimaryCurrency($amount, $row->currency); if ($row->type === 'credit') { $totalIncomeConverted += $converted; } else { $totalExpenseConverted += $converted; } $result[] = [ 'id' => $row->id, 'description' => $row->description, 'amount' => round($amount, 2), 'amount_converted' => round($converted, 2), 'currency' => $row->currency, 'type' => $row->type, 'source_type' => 'transaction', 'status' => $row->status, 'date' => $row->date, 'days_until' => (int) max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1), 'account' => $row->account_name, 'category' => $row->category_name, 'category_icon' => $row->category_icon, 'cost_center' => $row->cost_center_name, ]; } // 2. Cuotas de pasivos pendientes $pendingInstallments = DB::select(" SELECT li.id, la.name as description, li.installment_amount as amount, 'debit' as type, li.due_date as date, li.status, la.currency, a.name as account_name 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.due_date <= ? AND li.deleted_at IS NULL ORDER BY li.due_date ASC ", [$this->userId, $today, $endDate]); foreach ($pendingInstallments as $row) { $amount = abs($row->amount); $converted = $this->convertToPrimaryCurrency($amount, $row->currency); $totalExpenseConverted += $converted; $result[] = [ 'id' => $row->id, 'description' => $row->description . ' (Cuota)', 'amount' => round($amount, 2), 'amount_converted' => round($converted, 2), 'currency' => $row->currency, 'type' => 'debit', 'source_type' => 'liability_installment', 'status' => $row->status, 'date' => $row->date, 'days_until' => (int) max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1), 'account' => $row->account_name, 'category' => null, 'category_icon' => null, 'cost_center' => null, ]; } // 3. Proyecciones de recurrencias activas $recurrences = DB::select(" SELECT rt.id, rt.name, rt.transaction_description as description, rt.planned_amount as amount, rt.type, rt.frequency, rt.day_of_month, rt.start_date, rt.end_date, COALESCE(a.currency, 'EUR') as currency, a.name as account_name, c.name as category_name, c.icon as category_icon, cc.name as cost_center_name FROM recurring_templates rt LEFT JOIN accounts a ON rt.account_id = a.id LEFT JOIN categories c ON rt.category_id = c.id LEFT JOIN cost_centers cc ON rt.cost_center_id = cc.id WHERE rt.user_id = ? AND rt.is_active = 1 AND rt.deleted_at IS NULL AND (rt.end_date IS NULL OR rt.end_date >= ?) ", [$this->userId, $today]); foreach ($recurrences as $rec) { // Calcular próximas ejecuciones dentro del período $nextDates = $this->getNextRecurrenceDates($rec, $today, $endDate); foreach ($nextDates as $nextDate) { $amount = abs($rec->amount); $converted = $this->convertToPrimaryCurrency($amount, $rec->currency); if ($rec->type === 'credit') { $totalIncomeConverted += $converted; } else { $totalExpenseConverted += $converted; } $result[] = [ 'id' => $rec->id, 'description' => $rec->name . ' (Recurrente)', 'amount' => round($amount, 2), 'amount_converted' => round($converted, 2), 'currency' => $rec->currency, 'type' => $rec->type, 'source_type' => 'recurring', 'status' => 'projected', 'date' => $nextDate, 'days_until' => (int) max(0, Carbon::parse($nextDate)->diffInDays(now(), false) * -1), 'account' => $rec->account_name, 'category' => $rec->category_name, 'category_icon' => $rec->category_icon, 'cost_center' => $rec->cost_center_name, ]; } } // Ordenar por fecha usort($result, fn($a, $b) => strcmp($a['date'], $b['date'])); return response()->json([ 'data' => $result, 'currency' => $this->primaryCurrency, 'summary' => [ 'total_transactions' => count($result), 'total_income' => round($totalIncomeConverted, 2), 'total_expense' => round($totalExpenseConverted, 2), 'net_impact' => round($totalIncomeConverted - $totalExpenseConverted, 2), ], ]); } /** * Transacciones vencidas (pendientes de pago) * Incluye: cuotas de pasivos vencidas, transacciones pendientes/scheduled pasadas, * y recurrencias que deberían haber ejecutado pero no lo hicieron */ public function overdueTransactions(Request $request) { \Log::info('overdueTransactions called'); try { $this->init(); $today = now()->format('Y-m-d'); $result = []; $totalOverdueConverted = 0; // 1. Cuotas de pasivos vencidas $overdueInstallments = DB::select(" SELECT li.id, la.name as description, li.installment_amount as amount, li.due_date, la.currency, DATEDIFF(?, li.due_date) as days_overdue, a.name as account_name 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 != 'paid' AND li.due_date < ? AND li.deleted_at IS NULL ORDER BY li.due_date ASC ", [$today, $this->userId, $today]); foreach ($overdueInstallments as $row) { $converted = $this->convertToPrimaryCurrency($row->amount, $row->currency); $totalOverdueConverted += $converted; $result[] = [ 'id' => $row->id, 'description' => $row->description . ' (Cuota)', 'amount' => round($row->amount, 2), 'amount_converted' => round($converted, 2), 'currency' => $row->currency, 'due_date' => $row->due_date, 'days_overdue' => (int) $row->days_overdue, 'source_type' => 'liability_installment', 'account' => $row->account_name, 'category' => null, ]; } // 2. Transacciones pendientes/scheduled con fecha pasada $overdueTransactions = DB::select(" SELECT t.id, t.description, COALESCE(t.planned_amount, t.amount) as amount, t.planned_date as due_date, t.type, t.status, COALESCE(a.currency, 'EUR') as currency, DATEDIFF(?, t.planned_date) as days_overdue, a.name as account_name, c.name as category_name FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id LEFT JOIN categories c ON t.category_id = c.id WHERE t.user_id = ? AND t.status IN ('pending', 'scheduled') AND t.planned_date < ? AND t.deleted_at IS NULL ORDER BY t.planned_date ASC ", [$today, $this->userId, $today]); foreach ($overdueTransactions as $row) { $amount = abs($row->amount); $converted = $this->convertToPrimaryCurrency($amount, $row->currency); $totalOverdueConverted += $converted; $result[] = [ 'id' => $row->id, 'description' => $row->description, 'amount' => round($amount, 2), 'amount_converted' => round($converted, 2), 'currency' => $row->currency, 'due_date' => $row->due_date, 'days_overdue' => (int) $row->days_overdue, 'source_type' => 'transaction', 'type' => $row->type, 'status' => $row->status, 'account' => $row->account_name, 'category' => $row->category_name, ]; } // 3. Instancias de recorrências pendentes e vencidas $overdueRecurringInstances = DB::select(" SELECT ri.id, rt.name as description, ri.planned_amount as amount, ri.due_date, COALESCE(a.currency, 'EUR') as currency, DATEDIFF(?, ri.due_date) as days_overdue, a.name as account_name, c.name as category_name, rt.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 LEFT JOIN categories c ON rt.category_id = c.id WHERE ri.user_id = ? AND ri.status = 'pending' AND ri.due_date < ? AND ri.deleted_at IS NULL ORDER BY ri.due_date ASC ", [$today, $this->userId, $today]); foreach ($overdueRecurringInstances as $row) { $amount = abs($row->amount); $converted = $this->convertToPrimaryCurrency($amount, $row->currency); $totalOverdueConverted += $converted; $result[] = [ 'id' => $row->id, 'description' => $row->description . ' (Recorrente)', 'amount' => round($amount, 2), 'amount_converted' => round($converted, 2), 'currency' => $row->currency, 'due_date' => $row->due_date, 'days_overdue' => (int) $row->days_overdue, 'source_type' => 'recurring_instance', 'type' => $row->type, 'status' => 'pending', 'account' => $row->account_name, 'category' => $row->category_name, ]; } // Ordenar por días de atraso (más atrasado primero) usort($result, fn($a, $b) => $b['days_overdue'] <=> $a['days_overdue']); return response()->json([ 'data' => $result, 'currency' => $this->primaryCurrency, 'summary' => [ 'total_overdue' => count($result), 'total_amount' => round($totalOverdueConverted, 2), ], ]); } catch (\Throwable $e) { \Log::error('overdueTransactions error: ' . $e->getMessage() . ' at line ' . $e->getLine()); return response()->json([ 'error' => $e->getMessage(), 'line' => $e->getLine(), 'file' => $e->getFile() ], 500); } } /** * Resumen ejecutivo completo */ public function executiveSummary(Request $request) { $this->init(); $startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d')); $endDate = $request->get('end_date', now()->format('Y-m-d')); // Totales del período $totals = DB::select(" SELECT COALESCE(a.currency, 'EUR') as currency, SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income, SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense, COUNT(*) as transactions FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.effective_date BETWEEN ? AND ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY COALESCE(a.currency, 'EUR') ", [$this->userId, $startDate, $endDate]); $totalIncome = 0; $totalExpense = 0; $totalTransactions = 0; $byCurrency = []; foreach ($totals as $row) { $totalIncome += $this->convertToPrimaryCurrency($row->income, $row->currency); $totalExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency); $totalTransactions += $row->transactions; $byCurrency[$row->currency] = [ 'income' => round($row->income, 2), 'expense' => round($row->expense, 2), ]; } // Top 5 categorías de gasto $topCategories = DB::select(" SELECT c.name, c.icon, c.color, COALESCE(a.currency, 'EUR') as currency, SUM(ABS(t.amount)) as total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.type = 'debit' AND t.effective_date BETWEEN ? AND ? AND t.deleted_at IS NULL AND {$this->excludeTransfers()} GROUP BY c.id, c.name, c.icon, c.color, COALESCE(a.currency, 'EUR') ", [$this->userId, $startDate, $endDate]); $categoryTotals = []; foreach ($topCategories as $row) { $name = $row->name ?? 'Sin categoría'; if (!isset($categoryTotals[$name])) { $categoryTotals[$name] = [ 'name' => $name, 'icon' => $row->icon, 'color' => $row->color, 'total' => 0, ]; } $categoryTotals[$name]['total'] += $this->convertToPrimaryCurrency($row->total, $row->currency); } usort($categoryTotals, fn($a, $b) => $b['total'] <=> $a['total']); $top5Categories = array_slice($categoryTotals, 0, 5); // Cuentas $accounts = Account::where('user_id', $this->userId) ->where('include_in_total', true) ->get(); $totalBalance = 0; foreach ($accounts as $acc) { $totalBalance += $this->convertToPrimaryCurrency($acc->current_balance, $acc->currency); } return response()->json([ 'period' => ['start' => $startDate, 'end' => $endDate], 'currency' => $this->primaryCurrency, 'totals' => [ 'income' => round($totalIncome, 2), 'expense' => round($totalExpense, 2), 'balance' => round($totalIncome - $totalExpense, 2), 'savings_rate' => $totalIncome > 0 ? round((($totalIncome - $totalExpense) / $totalIncome) * 100, 1) : 0, 'transactions' => $totalTransactions, 'by_currency' => $byCurrency, ], 'accounts' => [ 'count' => $accounts->count(), 'total_balance' => round($totalBalance, 2), ], 'top_expense_categories' => array_map(function($c) { return [ 'name' => $c['name'], 'icon' => $c['icon'], 'color' => $c['color'], 'total' => round($c['total'], 2), ]; }, $top5Categories), ]); } /** * Calcula las próximas fechas de ejecución de una recurrencia dentro de un período */ private function getNextRecurrenceDates($recurrence, $startDate, $endDate) { $dates = []; $start = Carbon::parse($startDate); $end = Carbon::parse($endDate); $recStart = Carbon::parse($recurrence->start_date); $recEnd = $recurrence->end_date ? Carbon::parse($recurrence->end_date) : null; // Si la recurrencia termina antes del período, no hay fechas if ($recEnd && $recEnd->lt($start)) { return $dates; } // Calcular la primera fecha dentro del período $current = $recStart->copy(); // Avanzar hasta estar dentro del período (EXCLUYE startDate para evitar duplicados) while ($current->lte($start)) { $current = $this->advanceToNextOccurrence($current, $recurrence); if ($current->gt($end)) { return $dates; } } // Generar fechas hasta el fin del período $maxIterations = 100; // Prevenir bucles infinitos $iterations = 0; while ($current->lte($end) && $iterations < $maxIterations) { // Verificar que no pase de la fecha de fin de la recurrencia if ($recEnd && $current->gt($recEnd)) { break; } $dates[] = $current->format('Y-m-d'); $current = $this->advanceToNextOccurrence($current, $recurrence); $iterations++; } return $dates; } /** * Avanza a la próxima ocurrencia según la frecuencia */ private function advanceToNextOccurrence($date, $recurrence) { $next = $date->copy(); switch ($recurrence->frequency) { case 'daily': $next->addDays($recurrence->frequency_interval ?? 1); break; case 'weekly': $next->addWeeks($recurrence->frequency_interval ?? 1); break; case 'biweekly': $next->addWeeks(2); break; case 'monthly': $next->addMonths($recurrence->frequency_interval ?? 1); if ($recurrence->day_of_month) { $next->day = min($recurrence->day_of_month, $next->daysInMonth); } break; case 'bimonthly': $next->addMonths(2); if ($recurrence->day_of_month) { $next->day = min($recurrence->day_of_month, $next->daysInMonth); } break; case 'quarterly': $next->addMonths(3); if ($recurrence->day_of_month) { $next->day = min($recurrence->day_of_month, $next->daysInMonth); } break; case 'semiannual': $next->addMonths(6); if ($recurrence->day_of_month) { $next->day = min($recurrence->day_of_month, $next->daysInMonth); } break; case 'annual': $next->addYears($recurrence->frequency_interval ?? 1); break; default: $next->addMonths(1); } return $next; } /** * Obtiene las recurrencias que deberían haber ejecutado pero no lo hicieron * Busca la última ejecución esperada y verifica si existe una transacción para esa fecha */ private function getOverdueRecurrences($today) { $result = []; $todayCarbon = Carbon::parse($today); // Obtener todas las recurrencias activas $recurrences = DB::select(" SELECT rt.id, rt.name, rt.transaction_description as description, rt.planned_amount as amount, rt.type, rt.frequency, rt.day_of_month, rt.start_date, rt.end_date, rt.last_generated_date, COALESCE(a.currency, 'EUR') as currency, a.name as account_name, c.name as category_name FROM recurring_templates rt LEFT JOIN accounts a ON rt.account_id = a.id LEFT JOIN categories c ON rt.category_id = c.id WHERE rt.user_id = ? AND rt.is_active = 1 AND rt.deleted_at IS NULL ", [$this->userId]); foreach ($recurrences as $rec) { // Calcular la fecha de la última ejecución esperada $expectedDate = $this->getLastExpectedExecutionDate($rec, $todayCarbon); if (!$expectedDate) { continue; } // Verificar si ya existe una transacción para esta recurrencia en esa fecha // Buscamos por descripción similar y fecha cercana (±2 días) $existingTransaction = DB::selectOne(" SELECT id FROM transactions WHERE user_id = ? AND (description LIKE ? OR description LIKE ?) AND effective_date BETWEEN DATE_SUB(?, INTERVAL 2 DAY) AND DATE_ADD(?, INTERVAL 2 DAY) AND deleted_at IS NULL LIMIT 1 ", [ $this->userId, '%' . $rec->name . '%', '%' . ($rec->description ?? '') . '%', $expectedDate->format('Y-m-d'), $expectedDate->format('Y-m-d') ]); // Si no existe transacción y la fecha esperada es anterior a hoy, está vencida if (!$existingTransaction && $expectedDate->lt($todayCarbon)) { $daysOverdue = abs($expectedDate->diffInDays($todayCarbon)); $result[] = [ 'id' => $rec->id, 'description' => $rec->name, 'amount' => round(abs($rec->amount), 2), 'currency' => $rec->currency, 'due_date' => $expectedDate->format('Y-m-d'), 'days_overdue' => (int) $daysOverdue, 'source_type' => 'recurring_overdue', 'type' => $rec->type, 'status' => 'not_executed', 'account' => $rec->account_name, 'category' => $rec->category_name, ]; } } return $result; } /** * Calcula la fecha de la última ejecución esperada para una recurrencia */ private function getLastExpectedExecutionDate($recurrence, $today) { $startDate = Carbon::parse($recurrence->start_date); // Si aún no ha llegado la fecha de inicio, no hay ejecución esperada if ($startDate->gt($today)) { return null; } // Si tiene fecha de fin y ya pasó, usar la fecha de fin $endDate = $recurrence->end_date ? Carbon::parse($recurrence->end_date) : null; $referenceDate = ($endDate && $endDate->lt($today)) ? $endDate : $today; // Calcular la fecha esperada según la frecuencia switch ($recurrence->frequency) { case 'monthly': $dayOfMonth = $recurrence->day_of_month ?? $startDate->day; $expectedDate = $referenceDate->copy()->day(min($dayOfMonth, $referenceDate->daysInMonth)); // Si la fecha calculada es posterior a hoy, retroceder un mes if ($expectedDate->gt($today)) { $expectedDate->subMonth(); $expectedDate->day = min($dayOfMonth, $expectedDate->daysInMonth); } return $expectedDate; case 'weekly': $dayOfWeek = $startDate->dayOfWeek; $expectedDate = $referenceDate->copy()->startOfWeek()->addDays($dayOfWeek); if ($expectedDate->gt($today)) { $expectedDate->subWeek(); } return $expectedDate; case 'biweekly': $dayOfWeek = $startDate->dayOfWeek; $weeksSinceStart = $startDate->diffInWeeks($referenceDate); $biweeklyPeriods = floor($weeksSinceStart / 2); $expectedDate = $startDate->copy()->addWeeks($biweeklyPeriods * 2); if ($expectedDate->gt($today)) { $expectedDate->subWeeks(2); } return $expectedDate; case 'quarterly': $dayOfMonth = $recurrence->day_of_month ?? $startDate->day; $quarterMonth = floor(($referenceDate->month - 1) / 3) * 3 + 1; $expectedDate = $referenceDate->copy()->month($quarterMonth)->day(min($dayOfMonth, Carbon::create($referenceDate->year, $quarterMonth, 1)->daysInMonth)); if ($expectedDate->gt($today)) { $expectedDate->subMonths(3); } return $expectedDate; case 'annual': $expectedDate = Carbon::create($referenceDate->year, $startDate->month, min($startDate->day, Carbon::create($referenceDate->year, $startDate->month, 1)->daysInMonth)); if ($expectedDate->gt($today)) { $expectedDate->subYear(); } return $expectedDate; default: return null; } } }