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); // Si se quiere agrupar por categoría padre, obtenemos el nombre del padre 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 { // Sin agrupar: cada subcategoría se muestra individualmente $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 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]); } // 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 */ 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')); $data = DB::select(" SELECT cc.id as cost_center_id, cc.name as cost_center_name, cc.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()} AND t.cost_center_id IS NOT NULL GROUP BY cc.id, cc.name, cc.color, COALESCE(a.currency, 'EUR') ORDER BY expense DESC ", [$this->userId, $startDate, $endDate]); // Agrupar por centro de costo $byCostCenter = []; foreach ($data as $row) { $ccId = $row->cost_center_id; if (!isset($byCostCenter[$ccId])) { $byCostCenter[$ccId] = [ 'id' => $ccId, 'name' => $row->cost_center_name, 'color' => $row->color ?? '#6b7280', 'income_converted' => 0, 'expense_converted' => 0, 'by_currency' => [], ]; } $byCostCenter[$ccId]['by_currency'][$row->currency] = [ 'income' => round($row->income, 2), 'expense' => round($row->expense, 2), ]; $byCostCenter[$ccId]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency); $byCostCenter[$ccId]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency); } $result = array_map(function($cc) { return [ 'id' => $cc['id'], 'name' => $cc['name'], 'color' => $cc['color'], 'income' => round($cc['income_converted'], 2), 'expense' => round($cc['expense_converted'], 2), 'balance' => round($cc['income_converted'] - $cc['expense_converted'], 2), 'by_currency' => $cc['by_currency'], ]; }, $byCostCenter); 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'); // Histórico por divisa $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); } // Mes actual por divisa $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; $projectedExpense = ($currExpense / $daysElapsed) * $daysInMonth; $projectedIncome = ($currIncome / $daysElapsed) * $daysInMonth; return response()->json([ 'historical_average' => [ 'income' => round($histIncome, 2), 'expense' => round($histExpense, 2), ], 'current_month' => [ 'income' => round($currIncome, 2), 'expense' => round($currExpense, 2), 'days_elapsed' => $daysElapsed, 'days_remaining' => $daysRemaining, ], 'projection' => [ 'income' => round($projectedIncome, 2), 'expense' => round($projectedExpense, 2), 'balance' => round($projectedIncome - $projectedExpense, 2), ], 'vs_average' => [ 'income' => $histIncome > 0 ? round((($projectedIncome - $histIncome) / $histIncome) * 100, 1) : 0, 'expense' => $histExpense > 0 ? round((($projectedExpense - $histExpense) / $histExpense) * 100, 1) : 0, ], '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'; $amount = abs($t->amount); $converted = $this->convertToPrimaryCurrency($amount, $currency); if (!isset($byCurrency[$currency])) { $byCurrency[$currency] = ['income' => 0, 'expense' => 0]; } if ($t->type === 'credit') { $monthlyIncomeConverted += $converted; $byCurrency[$currency]['income'] += $amount; } else { $monthlyExpenseConverted += $converted; $byCurrency[$currency]['expense'] += $amount; } return [ 'id' => $t->id, 'description' => $t->description, 'amount' => $amount, '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, ], ]); } /** * 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_amount ?? 0; $paidAmount = $l->installments->where('status', 'paid')->sum('amount'); $pendingAmount = $l->installments->where('status', '!=', 'paid')->sum('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->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' => 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' => 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' => 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_items' => 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 y transacciones pendientes/scheduled pasadas */ public function overdueTransactions(Request $request) { $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, ]; } // 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), ], ]); } /** * 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 while ($current->lt($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; } }