userId = Auth::id(); $this->setPrimaryCurrency(); $this->loadExchangeRates(); // Verificar si hay datos suficientes para análisis $dataStatus = $this->checkDataSufficiency(); // Obtener datos base $financialSummary = $this->getFinancialSummary(); $cashFlowAnalysis = $this->analyzeCashFlow(); $categoryAnalysis = $this->analyzeCategories(); $trendAnalysis = $this->analyzeTrends(); // Calcular métricas detalladas $metrics = [ 'savings_capacity' => $this->calculateSavingsCapacity($cashFlowAnalysis), 'debt_control' => $this->calculateDebtControl($cashFlowAnalysis), 'budget_management' => $this->calculateBudgetManagement(), 'expense_efficiency' => $this->calculateExpenseEfficiency($categoryAnalysis), 'emergency_fund' => $this->calculateEmergencyFund($cashFlowAnalysis), 'financial_stability' => $this->calculateFinancialStability($trendAnalysis), ]; // Calcular puntuación general ponderada $weights = [ 'savings_capacity' => 0.25, 'debt_control' => 0.20, 'budget_management' => 0.15, 'expense_efficiency' => 0.15, 'emergency_fund' => 0.15, 'financial_stability' => 0.10, ]; $overallScore = 0; foreach ($metrics as $key => $metric) { $overallScore += $metric['score'] * $weights[$key]; } $overallScore = round($overallScore); // Determinar nivel de salud $healthLevel = $this->getHealthLevel($overallScore); // Generar insights avanzados $insights = $this->generateAdvancedInsights($metrics, $categoryAnalysis, $trendAnalysis); // Generar recomendaciones personalizadas $recommendations = $this->generateRecommendations($metrics, $categoryAnalysis); return response()->json([ 'overall_score' => $overallScore, 'health_level' => $healthLevel, 'last_updated' => now()->toIso8601String(), 'currency' => $this->primaryCurrency, // Estado de datos 'data_status' => $dataStatus, // Resumen financiero 'summary' => [ 'total_assets' => $financialSummary['total_assets'], 'total_liabilities' => $financialSummary['total_liabilities'], 'net_worth' => $financialSummary['net_worth'], 'monthly_income' => $cashFlowAnalysis['avg_monthly_income'], 'monthly_expenses' => $cashFlowAnalysis['avg_monthly_expenses'], 'monthly_savings' => $cashFlowAnalysis['avg_monthly_savings'], 'savings_rate' => $cashFlowAnalysis['savings_rate'], 'accounts_by_currency' => $financialSummary['by_currency'], ], // Métricas detalladas 'metrics' => $metrics, // Análisis de categorías 'category_analysis' => [ 'top_expenses' => $categoryAnalysis['top_expenses'], 'expense_distribution' => $categoryAnalysis['distribution'], 'category_trends' => $categoryAnalysis['trends'], 'anomalies' => $categoryAnalysis['anomalies'], ], // Análisis de tendencias 'trends' => [ 'income_trend' => $trendAnalysis['income_trend'], 'expense_trend' => $trendAnalysis['expense_trend'], 'savings_trend' => $trendAnalysis['savings_trend'], 'monthly_comparison' => $trendAnalysis['monthly_comparison'], 'monthly_data' => $trendAnalysis['monthly_data'], ], // Insights y recomendaciones 'insights' => $insights, 'recommendations' => $recommendations, // Proyección 'projection' => $this->calculateProjection($cashFlowAnalysis, $trendAnalysis), ]); } /** * Verificar si hay datos suficientes para análisis */ private function checkDataSufficiency() { $accountsCount = Account::where('user_id', $this->userId)->count(); $transactionsCount = Transaction::where('user_id', $this->userId)->count(); $categoriesCount = Category::where('user_id', $this->userId)->count(); // Transacciones de los últimos 30 días $recentTransactions = Transaction::where('user_id', $this->userId) ->where('effective_date', '>=', now()->subDays(30)) ->count(); // Determinar nivel de suficiencia $hasSufficientData = $accountsCount >= 1 && $transactionsCount >= 10; $hasMinimalData = $accountsCount >= 1 || $transactionsCount >= 1; // Mensaje apropiado $message = null; $level = 'sufficient'; if ($accountsCount === 0 && $transactionsCount === 0) { $level = 'no_data'; $message = 'No hay datos registrados. Añade cuentas y transacciones para comenzar el análisis.'; } elseif ($accountsCount === 0) { $level = 'insufficient'; $message = 'Añade al menos una cuenta bancaria para un análisis más preciso.'; } elseif ($transactionsCount < 10) { $level = 'limited'; $message = 'Hay pocos datos para un análisis completo. Registra más transacciones para mejorar la precisión.'; } elseif ($recentTransactions === 0) { $level = 'outdated'; $message = 'No hay transacciones recientes. Los datos pueden estar desactualizados.'; } return [ 'has_sufficient_data' => $hasSufficientData, 'has_minimal_data' => $hasMinimalData, 'level' => $level, 'message' => $message, 'counts' => [ 'accounts' => $accountsCount, 'transactions' => $transactionsCount, 'categories' => $categoriesCount, 'recent_transactions' => $recentTransactions, ], ]; } /** * Establecer moneda principal del usuario */ private function setPrimaryCurrency() { $account = Account::where('user_id', $this->userId) ->where('include_in_total', true) ->orderByDesc('current_balance') ->first(); $this->primaryCurrency = $account->currency ?? 'EUR'; } /** * Cargar tasas de cambio */ private function loadExchangeRates() { // Tasas aproximadas vs EUR (en producción usar API de tasas de cambio) $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, ]; } /** * Convertir a moneda principal */ private function convertToPrimaryCurrency($amount, $fromCurrency) { if ($fromCurrency === $this->primaryCurrency) { return $amount; } $rateFrom = $this->exchangeRates[$fromCurrency] ?? 1; $rateTo = $this->exchangeRates[$this->primaryCurrency] ?? 1; return $amount * ($rateFrom / $rateTo); } /** * Obtener resumen financiero completo */ private function getFinancialSummary() { // Cuentas agrupadas por moneda $accounts = Account::where('user_id', $this->userId) ->where('is_active', true) ->get(); $byCurrency = []; $totalAssets = 0; foreach ($accounts as $account) { $currency = $account->currency ?? 'EUR'; if (!isset($byCurrency[$currency])) { $byCurrency[$currency] = [ 'currency' => $currency, 'balance' => 0, 'accounts' => [], ]; } $byCurrency[$currency]['balance'] += $account->current_balance; $byCurrency[$currency]['accounts'][] = [ 'name' => $account->name, 'type' => $account->type, 'balance' => round($account->current_balance, 2), ]; if ($account->include_in_total) { $totalAssets += $this->convertToPrimaryCurrency($account->current_balance, $currency); } } // Pasivos $liabilities = LiabilityAccount::where('user_id', $this->userId) ->where('status', 'active') ->get(); $totalLiabilities = 0; foreach ($liabilities as $liability) { $currency = $liability->currency ?? 'EUR'; $balance = $liability->remaining_balance ?? 0; $totalLiabilities += $this->convertToPrimaryCurrency($balance, $currency); } return [ 'total_assets' => round($totalAssets, 2), 'total_liabilities' => round($totalLiabilities, 2), 'net_worth' => round($totalAssets - $totalLiabilities, 2), 'by_currency' => array_values($byCurrency), ]; } /** * Analizar flujo de caja con soporte multi-divisa */ private function analyzeCashFlow() { // Datos de los últimos 6 meses para mejor análisis $months = 6; $startDate = now()->subMonths($months)->startOfMonth(); // Obtener transacciones con moneda de la cuenta $transactions = DB::select(" SELECT DATE_FORMAT(t.effective_date, '%Y-%m') as month, t.type, t.amount, 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.effective_date >= ? AND t.deleted_at IS NULL AND (t.is_transfer IS NULL OR t.is_transfer = 0) ", [$this->userId, $startDate->format('Y-m-d')]); // Agrupar por mes y convertir monedas $monthlyData = []; $byCurrency = []; foreach ($transactions as $t) { $currency = $t->currency ?? 'EUR'; $amount = abs($t->amount); $convertedAmount = $this->convertToPrimaryCurrency($amount, $currency); // Por mes if (!isset($monthlyData[$t->month])) { $monthlyData[$t->month] = ['income' => 0, 'expenses' => 0]; } if ($t->type === 'credit') { $monthlyData[$t->month]['income'] += $convertedAmount; } else { $monthlyData[$t->month]['expenses'] += $convertedAmount; } // Por moneda if (!isset($byCurrency[$currency])) { $byCurrency[$currency] = ['income' => 0, 'expenses' => 0]; } if ($t->type === 'credit') { $byCurrency[$currency]['income'] += $amount; } else { $byCurrency[$currency]['expenses'] += $amount; } } // Ordenar por mes ksort($monthlyData); $totalIncome = 0; $totalExpenses = 0; $formattedMonthlyData = []; foreach ($monthlyData as $month => $data) { $totalIncome += $data['income']; $totalExpenses += $data['expenses']; $formattedMonthlyData[] = [ 'month' => $month, 'income' => round($data['income'], 2), 'expenses' => round($data['expenses'], 2), 'savings' => round($data['income'] - $data['expenses'], 2), ]; } $monthsWithData = max(1, count($monthlyData)); $avgIncome = $totalIncome / $monthsWithData; $avgExpenses = $totalExpenses / $monthsWithData; $avgSavings = $avgIncome - $avgExpenses; $savingsRate = $avgIncome > 0 ? ($avgSavings / $avgIncome) * 100 : 0; // Volatilidad del flujo de caja $incomeValues = array_column($formattedMonthlyData, 'income'); $expenseValues = array_column($formattedMonthlyData, 'expenses'); // Formatear datos por moneda $formattedByCurrency = []; foreach ($byCurrency as $currency => $data) { $formattedByCurrency[] = [ 'currency' => $currency, 'income' => round($data['income'], 2), 'expenses' => round($data['expenses'], 2), 'savings' => round($data['income'] - $data['expenses'], 2), 'income_converted' => round($this->convertToPrimaryCurrency($data['income'], $currency), 2), 'expenses_converted' => round($this->convertToPrimaryCurrency($data['expenses'], $currency), 2), ]; } return [ 'monthly_data' => $formattedMonthlyData, 'by_currency' => $formattedByCurrency, 'total_income' => round($totalIncome, 2), 'total_expenses' => round($totalExpenses, 2), 'avg_monthly_income' => round($avgIncome, 2), 'avg_monthly_expenses' => round($avgExpenses, 2), 'avg_monthly_savings' => round($avgSavings, 2), 'savings_rate' => round($savingsRate, 1), 'income_volatility' => $this->calculateVolatility($incomeValues), 'expense_volatility' => $this->calculateVolatility($expenseValues), ]; } /** * Calcular volatilidad (desviación estándar relativa) */ private function calculateVolatility(array $values) { if (count($values) < 2) return 0; $mean = array_sum($values) / count($values); if ($mean == 0) return 0; $variance = 0; foreach ($values as $value) { $variance += pow($value - $mean, 2); } $variance /= count($values); $stdDev = sqrt($variance); return round(($stdDev / $mean) * 100, 1); // Coeficiente de variación } /** * Analizar categorías de gastos con soporte multi-divisa */ private function analyzeCategories() { $startDate = now()->subMonths(3)->startOfMonth(); $previousStartDate = now()->subMonths(6)->startOfMonth(); // Top gastos actuales (últimos 3 meses) con conversión de moneda $rawExpenses = DB::select(" SELECT c.id, c.name, c.color, c.icon, t.amount, COALESCE(a.currency, 'EUR') as currency FROM transactions t 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 >= ? AND t.deleted_at IS NULL AND (t.is_transfer IS NULL OR t.is_transfer = 0) ", [$this->userId, $startDate->format('Y-m-d')]); // Agrupar y convertir a moneda principal $categoryTotals = []; foreach ($rawExpenses as $exp) { $catId = $exp->id; if (!isset($categoryTotals[$catId])) { $categoryTotals[$catId] = [ 'id' => $exp->id, 'name' => $exp->name, 'color' => $exp->color ?? '#6b7280', 'icon' => $exp->icon ?? 'bi-tag', 'total' => 0, 'total_original' => [], 'transaction_count' => 0, ]; } $amount = abs($exp->amount); $convertedAmount = $this->convertToPrimaryCurrency($amount, $exp->currency); $categoryTotals[$catId]['total'] += $convertedAmount; $categoryTotals[$catId]['transaction_count']++; // Guardar por moneda original if (!isset($categoryTotals[$catId]['total_original'][$exp->currency])) { $categoryTotals[$catId]['total_original'][$exp->currency] = 0; } $categoryTotals[$catId]['total_original'][$exp->currency] += $amount; } // Ordenar por total usort($categoryTotals, fn($a, $b) => $b['total'] <=> $a['total']); $categoryTotals = array_slice($categoryTotals, 0, 15); // Calcular total para porcentajes $totalExpenses = array_sum(array_column($categoryTotals, 'total')); // Enriquecer con porcentajes $topExpenses = array_map(function($cat) use ($totalExpenses) { return [ 'id' => $cat['id'], 'name' => $cat['name'], 'color' => $cat['color'], 'icon' => $cat['icon'], 'total' => round($cat['total'], 2), 'percentage' => $totalExpenses > 0 ? round(($cat['total'] / $totalExpenses) * 100, 1) : 0, 'transaction_count' => $cat['transaction_count'], 'avg_transaction' => $cat['transaction_count'] > 0 ? round($cat['total'] / $cat['transaction_count'], 2) : 0, 'by_currency' => $cat['total_original'], ]; }, $categoryTotals); // Distribución por tipo de gasto $distribution = $this->getExpenseDistribution($startDate); // Tendencias por categoría (comparar con período anterior) $trends = $this->getCategoryTrends($startDate, $previousStartDate); // Detectar anomalías $anomalies = $this->detectAnomalies($topExpenses, $trends); return [ 'top_expenses' => $topExpenses, 'distribution' => $distribution, 'trends' => $trends, 'anomalies' => $anomalies, ]; } /** * Obtener distribución de gastos por tipo con soporte multi-divisa */ private function getExpenseDistribution($startDate) { // Categorizar gastos en: fijos, variables, discrecionales $fixedCategories = ['Aluguel', 'Alquiler', 'Hipoteca', 'Seguros', 'Internet/Telefone', 'Eletricidade', 'Água', 'Gás', 'Empréstimos', 'Universidade']; $variableCategories = ['Supermercado', 'Transporte', 'Combustible', 'Salud', 'Farmácia', 'Mercado']; // Obtener transacciones con moneda de cuenta $expenses = DB::select(" SELECT c.name, t.amount, COALESCE(a.currency, 'EUR') as currency FROM transactions t 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 >= ? AND t.deleted_at IS NULL AND (t.is_transfer IS NULL OR t.is_transfer = 0) ", [$this->userId, $startDate->format('Y-m-d')]); $fixed = 0; $variable = 0; $discretionary = 0; $byCurrency = ['fixed' => [], 'variable' => [], 'discretionary' => []]; foreach ($expenses as $exp) { $amount = abs($exp->amount); $convertedAmount = $this->convertToPrimaryCurrency($amount, $exp->currency); if (in_array($exp->name, $fixedCategories)) { $fixed += $convertedAmount; if (!isset($byCurrency['fixed'][$exp->currency])) { $byCurrency['fixed'][$exp->currency] = 0; } $byCurrency['fixed'][$exp->currency] += $amount; } elseif (in_array($exp->name, $variableCategories)) { $variable += $convertedAmount; if (!isset($byCurrency['variable'][$exp->currency])) { $byCurrency['variable'][$exp->currency] = 0; } $byCurrency['variable'][$exp->currency] += $amount; } else { $discretionary += $convertedAmount; if (!isset($byCurrency['discretionary'][$exp->currency])) { $byCurrency['discretionary'][$exp->currency] = 0; } $byCurrency['discretionary'][$exp->currency] += $amount; } } $total = $fixed + $variable + $discretionary; return [ 'fixed' => [ 'amount' => round($fixed, 2), 'percentage' => $total > 0 ? round(($fixed / $total) * 100, 1) : 0, 'by_currency' => $byCurrency['fixed'], ], 'variable' => [ 'amount' => round($variable, 2), 'percentage' => $total > 0 ? round(($variable / $total) * 100, 1) : 0, 'by_currency' => $byCurrency['variable'], ], 'discretionary' => [ 'amount' => round($discretionary, 2), 'percentage' => $total > 0 ? round(($discretionary / $total) * 100, 1) : 0, 'by_currency' => $byCurrency['discretionary'], ], ]; } /** * Obtener tendencias por categoría con soporte multi-divisa */ private function getCategoryTrends($currentStart, $previousStart) { $currentEnd = now(); $previousEnd = $currentStart->copy()->subDay(); // Período actual - obtener transacciones individuales con moneda $currentRaw = DB::select(" SELECT c.name, t.amount, COALESCE(a.currency, 'EUR') as currency FROM transactions t 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 (t.is_transfer IS NULL OR t.is_transfer = 0) ", [$this->userId, $currentStart->format('Y-m-d'), $currentEnd->format('Y-m-d')]); // Agrupar y convertir período actual $currentMap = []; foreach ($currentRaw as $t) { if (!isset($currentMap[$t->name])) { $currentMap[$t->name] = ['total' => 0, 'by_currency' => []]; } $amount = abs($t->amount); $converted = $this->convertToPrimaryCurrency($amount, $t->currency); $currentMap[$t->name]['total'] += $converted; if (!isset($currentMap[$t->name]['by_currency'][$t->currency])) { $currentMap[$t->name]['by_currency'][$t->currency] = 0; } $currentMap[$t->name]['by_currency'][$t->currency] += $amount; } // Período anterior - obtener transacciones individuales con moneda $previousRaw = DB::select(" SELECT c.name, t.amount, COALESCE(a.currency, 'EUR') as currency FROM transactions t 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 (t.is_transfer IS NULL OR t.is_transfer = 0) ", [$this->userId, $previousStart->format('Y-m-d'), $previousEnd->format('Y-m-d')]); // Agrupar y convertir período anterior $previousMap = []; foreach ($previousRaw as $t) { if (!isset($previousMap[$t->name])) { $previousMap[$t->name] = ['total' => 0, 'by_currency' => []]; } $amount = abs($t->amount); $converted = $this->convertToPrimaryCurrency($amount, $t->currency); $previousMap[$t->name]['total'] += $converted; if (!isset($previousMap[$t->name]['by_currency'][$t->currency])) { $previousMap[$t->name]['by_currency'][$t->currency] = 0; } $previousMap[$t->name]['by_currency'][$t->currency] += $amount; } $trends = []; foreach ($currentMap as $name => $data) { $prevAmount = $previousMap[$name]['total'] ?? 0; $change = $prevAmount > 0 ? (($data['total'] - $prevAmount) / $prevAmount) * 100 : 0; if (abs($change) > 10) { // Solo mostrar cambios significativos $trends[] = [ 'category' => $name, 'current' => round($data['total'], 2), 'previous' => round($prevAmount, 2), 'change_percent' => round($change, 1), 'trend' => $change > 0 ? 'increasing' : 'decreasing', 'by_currency' => $data['by_currency'], ]; } } // Ordenar por cambio absoluto usort($trends, fn($a, $b) => abs($b['change_percent']) <=> abs($a['change_percent'])); return array_slice($trends, 0, 5); } /** * Detectar anomalías en gastos */ private function detectAnomalies($topExpenses, $trends) { $anomalies = []; // Categorías con aumento > 30% foreach ($trends as $trend) { if ($trend['change_percent'] > 30) { $anomalies[] = [ 'type' => 'spending_spike', 'severity' => $trend['change_percent'] > 50 ? 'high' : 'medium', 'category' => $trend['category'], 'message_key' => 'financialHealth.insights.spendingSpike', 'data' => [ 'category' => $trend['category'], 'increase' => $trend['change_percent'], ], ]; } } // Categoría que representa > 40% del gasto total foreach ($topExpenses as $expense) { if ($expense['percentage'] > 40) { $anomalies[] = [ 'type' => 'high_concentration', 'severity' => 'medium', 'category' => $expense['name'], 'message_key' => 'financialHealth.insights.highConcentration', 'data' => [ 'category' => $expense['name'], 'percentage' => $expense['percentage'], ], ]; } } return $anomalies; } /** * Analizar tendencias generales con soporte multi-divisa */ private function analyzeTrends() { $months = 6; $startDate = now()->subMonths($months)->startOfMonth(); // Obtener transacciones individuales con moneda $rawData = DB::select(" SELECT DATE_FORMAT(t.effective_date, '%Y-%m') as month, t.type, t.amount, 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.effective_date >= ? AND t.deleted_at IS NULL AND (t.is_transfer IS NULL OR t.is_transfer = 0) ORDER BY t.effective_date ", [$this->userId, $startDate->format('Y-m-d')]); // Agrupar por mes con conversión de moneda $monthlyTotals = []; $monthlyByCurrency = []; foreach ($rawData as $t) { $month = $t->month; if (!isset($monthlyTotals[$month])) { $monthlyTotals[$month] = ['income' => 0, 'expenses' => 0]; $monthlyByCurrency[$month] = ['income' => [], 'expenses' => []]; } $amount = abs($t->amount); $convertedAmount = $this->convertToPrimaryCurrency($amount, $t->currency); if ($t->type === 'credit') { $monthlyTotals[$month]['income'] += $convertedAmount; if (!isset($monthlyByCurrency[$month]['income'][$t->currency])) { $monthlyByCurrency[$month]['income'][$t->currency] = 0; } $monthlyByCurrency[$month]['income'][$t->currency] += $amount; } else { $monthlyTotals[$month]['expenses'] += $convertedAmount; if (!isset($monthlyByCurrency[$month]['expenses'][$t->currency])) { $monthlyByCurrency[$month]['expenses'][$t->currency] = 0; } $monthlyByCurrency[$month]['expenses'][$t->currency] += $amount; } } // Ordenar por mes ksort($monthlyTotals); // Preparar arrays para cálculos $incomeValues = array_column($monthlyTotals, 'income'); $expenseValues = array_column($monthlyTotals, 'expenses'); // Calcular tendencia lineal $incomeTrend = $this->calculateTrend($incomeValues); $expenseTrend = $this->calculateTrend($expenseValues); $savingsValues = []; foreach ($monthlyTotals as $month => $data) { $savingsValues[] = $data['income'] - $data['expenses']; } $savingsTrend = $this->calculateTrend($savingsValues); // Comparación mes actual vs anterior $currentMonth = now()->format('Y-m'); $lastMonth = now()->subMonth()->format('Y-m'); $current = $monthlyTotals[$currentMonth] ?? ['income' => 0, 'expenses' => 0]; $previous = $monthlyTotals[$lastMonth] ?? ['income' => 0, 'expenses' => 0]; $monthlyComparison = [ 'current_month' => $currentMonth, 'previous_month' => $lastMonth, 'income' => [ 'current' => round($current['income'], 2), 'previous' => round($previous['income'], 2), 'change' => $previous['income'] > 0 ? round(($current['income'] - $previous['income']) / $previous['income'] * 100, 1) : 0, 'by_currency' => $monthlyByCurrency[$currentMonth]['income'] ?? [], ], 'expenses' => [ 'current' => round($current['expenses'], 2), 'previous' => round($previous['expenses'], 2), 'change' => $previous['expenses'] > 0 ? round(($current['expenses'] - $previous['expenses']) / $previous['expenses'] * 100, 1) : 0, 'by_currency' => $monthlyByCurrency[$currentMonth]['expenses'] ?? [], ], ]; // Preparar datos mensuales $monthlyData = []; foreach ($monthlyTotals as $month => $data) { $monthlyData[] = [ 'month' => $month, 'income' => round($data['income'], 2), 'expenses' => round($data['expenses'], 2), 'savings' => round($data['income'] - $data['expenses'], 2), 'by_currency' => $monthlyByCurrency[$month] ?? [], ]; } return [ 'income_trend' => $incomeTrend, 'expense_trend' => $expenseTrend, 'savings_trend' => $savingsTrend, 'monthly_comparison' => $monthlyComparison, 'monthly_data' => $monthlyData, ]; } /** * Calcular tendencia lineal simple */ private function calculateTrend(array $values) { $n = count($values); if ($n < 2) return ['direction' => 'stable', 'strength' => 0]; // Regresión lineal simple $sumX = 0; $sumY = 0; $sumXY = 0; $sumX2 = 0; for ($i = 0; $i < $n; $i++) { $sumX += $i; $sumY += $values[$i]; $sumXY += $i * $values[$i]; $sumX2 += $i * $i; } $denominator = ($n * $sumX2 - $sumX * $sumX); if ($denominator == 0) return ['direction' => 'stable', 'strength' => 0]; $slope = ($n * $sumXY - $sumX * $sumY) / $denominator; $avgY = $sumY / $n; // Pendiente relativa al promedio $relativeSlope = $avgY != 0 ? ($slope / $avgY) * 100 : 0; $direction = 'stable'; if ($relativeSlope > 3) $direction = 'increasing'; if ($relativeSlope < -3) $direction = 'decreasing'; return [ 'direction' => $direction, 'strength' => round(abs($relativeSlope), 1), 'monthly_change' => round($slope, 2), ]; } /** * Calcular capacidad de ahorro */ private function calculateSavingsCapacity($cashFlow) { $savingsRate = $cashFlow['savings_rate']; // Puntuación basada en tasa de ahorro $score = match(true) { $savingsRate >= 30 => 100, $savingsRate >= 20 => 80 + (($savingsRate - 20) * 2), $savingsRate >= 10 => 60 + (($savingsRate - 10) * 2), $savingsRate >= 0 => 40 + ($savingsRate * 2), default => max(0, 40 + $savingsRate), }; return [ 'score' => round(min(100, max(0, $score))), 'savings_rate' => $savingsRate, 'monthly_income' => $cashFlow['avg_monthly_income'], 'monthly_expenses' => $cashFlow['avg_monthly_expenses'], 'monthly_savings' => $cashFlow['avg_monthly_savings'], 'status' => $savingsRate >= 20 ? 'excellent' : ($savingsRate >= 10 ? 'good' : ($savingsRate >= 0 ? 'needs_improvement' : 'negative')), ]; } /** * Calcular control de deudas con soporte multi-divisa */ private function calculateDebtControl($cashFlow) { $liabilities = LiabilityAccount::where('user_id', $this->userId) ->where('status', 'active') ->get(); // Convertir todas las deudas a moneda principal $totalDebt = 0; $totalCreditLimit = 0; $monthlyPayments = 0; $debtByCurrency = []; foreach ($liabilities as $liability) { $currency = $liability->currency ?? 'EUR'; $balance = $liability->remaining_balance ?? 0; $limit = $liability->credit_limit ?? 0; $payment = $liability->monthly_payment ?? 0; $totalDebt += $this->convertToPrimaryCurrency($balance, $currency); $totalCreditLimit += $this->convertToPrimaryCurrency($limit, $currency); $monthlyPayments += $this->convertToPrimaryCurrency($payment, $currency); if (!isset($debtByCurrency[$currency])) { $debtByCurrency[$currency] = 0; } $debtByCurrency[$currency] += $balance; } $totalCreditLimit = $totalCreditLimit ?: 1; $monthlyIncome = $cashFlow['avg_monthly_income']; // Ratios $debtToIncomeRatio = $monthlyIncome > 0 ? ($monthlyPayments / $monthlyIncome) * 100 : 0; $creditUtilization = ($totalDebt / $totalCreditLimit) * 100; // Puntuación $score = 100; if ($debtToIncomeRatio > 50) $score -= 50; elseif ($debtToIncomeRatio > 35) $score -= 30; elseif ($debtToIncomeRatio > 20) $score -= 15; if ($creditUtilization > 80) $score -= 20; elseif ($creditUtilization > 50) $score -= 10; // Bonus por no tener deudas if ($totalDebt == 0) $score = 100; return [ 'score' => round(max(0, $score)), 'total_debt' => round($totalDebt, 2), 'debt_by_currency' => $debtByCurrency, 'monthly_payments' => round($monthlyPayments, 2), 'debt_to_income_ratio' => round($debtToIncomeRatio, 1), 'credit_utilization' => round($creditUtilization, 1), 'active_debts' => $liabilities->count(), 'debt_free_date' => $this->calculateDebtFreeDate($liabilities), 'status' => $totalDebt == 0 ? 'debt_free' : ($debtToIncomeRatio <= 20 ? 'healthy' : ($debtToIncomeRatio <= 35 ? 'manageable' : 'concerning')), ]; } /** * Calcular fecha libre de deudas */ private function calculateDebtFreeDate($liabilities) { if ($liabilities->isEmpty()) return null; $latestEndDate = null; foreach ($liabilities as $liability) { if ($liability->end_date && (!$latestEndDate || $liability->end_date > $latestEndDate)) { $latestEndDate = $liability->end_date; } } return $latestEndDate?->format('Y-m-d'); } /** * Calcular gestión de presupuesto */ private function calculateBudgetManagement() { $currentMonth = now()->month; $currentYear = now()->year; $budgets = Budget::where('user_id', $this->userId) ->where('year', $currentYear) ->where('month', $currentMonth) ->where('is_active', true) ->with('category') ->get(); if ($budgets->isEmpty()) { return [ 'score' => 50, 'has_budgets' => false, 'status' => 'not_configured', 'total_budgets' => 0, 'exceeded_count' => 0, 'compliance_rate' => 0, ]; } $exceededCount = 0; $totalUsage = 0; $budgetDetails = []; foreach ($budgets as $budget) { $spent = $this->getBudgetSpent($budget); $usage = $budget->amount > 0 ? ($spent / $budget->amount) * 100 : 0; $totalUsage += $usage; if ($usage > 100) $exceededCount++; $budgetDetails[] = [ 'category' => $budget->category?->name ?? $budget->name, 'budgeted' => round($budget->amount, 2), 'spent' => round($spent, 2), 'usage_percent' => round($usage, 1), 'status' => $usage > 100 ? 'exceeded' : ($usage > 80 ? 'warning' : 'on_track'), ]; } $avgUsage = $totalUsage / $budgets->count(); $complianceRate = (($budgets->count() - $exceededCount) / $budgets->count()) * 100; // Puntuación $score = match(true) { $exceededCount == 0 && $avgUsage <= 90 => 100, $exceededCount == 0 => 85, $exceededCount == 1 => 70, $exceededCount == 2 => 55, default => max(20, 55 - ($exceededCount * 10)), }; return [ 'score' => round($score), 'has_budgets' => true, 'total_budgets' => $budgets->count(), 'exceeded_count' => $exceededCount, 'compliance_rate' => round($complianceRate, 1), 'avg_usage' => round($avgUsage, 1), 'budgets' => $budgetDetails, 'status' => $exceededCount == 0 ? 'on_track' : ($exceededCount <= 2 ? 'needs_attention' : 'exceeded'), ]; } /** * Obtener gasto de un presupuesto con soporte multi-divisa */ private function getBudgetSpent($budget) { $startDate = Carbon::createFromDate($budget->year, $budget->month, 1)->startOfMonth(); $endDate = $startDate->copy()->endOfMonth(); // Obtener transacciones con su moneda $transactions = DB::select(" SELECT t.amount, 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.category_id = ? AND t.type = 'debit' AND t.effective_date BETWEEN ? AND ? AND t.deleted_at IS NULL AND (t.is_transfer IS NULL OR t.is_transfer = 0) ", [$this->userId, $budget->category_id, $startDate->format('Y-m-d'), $endDate->format('Y-m-d')]); // Sumar con conversión de moneda $total = 0; foreach ($transactions as $t) { $amount = abs($t->amount); $total += $this->convertToPrimaryCurrency($amount, $t->currency); } return $total; } /** * Calcular eficiencia de gastos */ private function calculateExpenseEfficiency($categoryAnalysis) { $distribution = $categoryAnalysis['distribution']; // Puntuación basada en distribución (ideal: 50% fijos, 30% variables, 20% discrecionales) $fixedScore = 100 - abs($distribution['fixed']['percentage'] - 50) * 1.5; $variableScore = 100 - abs($distribution['variable']['percentage'] - 30) * 2; $discretionaryScore = $distribution['discretionary']['percentage'] <= 30 ? 100 : 100 - ($distribution['discretionary']['percentage'] - 30) * 3; $score = ($fixedScore * 0.4) + ($variableScore * 0.3) + ($discretionaryScore * 0.3); return [ 'score' => round(max(0, min(100, $score))), 'distribution' => $distribution, 'efficiency_rating' => $score >= 80 ? 'excellent' : ($score >= 60 ? 'good' : 'needs_optimization'), 'status' => $distribution['discretionary']['percentage'] <= 25 ? 'optimized' : ($distribution['discretionary']['percentage'] <= 35 ? 'acceptable' : 'high_discretionary'), ]; } /** * Calcular fondo de emergencia con soporte multi-divisa */ private function calculateEmergencyFund($cashFlow) { // Obtener cuentas líquidas con su moneda $accounts = Account::where('user_id', $this->userId) ->whereIn('type', ['checking', 'savings', 'cash']) ->where('include_in_total', true) ->get(['current_balance', 'currency']); // Convertir todas las cuentas a la moneda principal $liquidAssets = 0; $byCurrency = []; foreach ($accounts as $account) { $currency = $account->currency ?? 'EUR'; $balance = max(0, $account->current_balance ?? 0); $converted = $this->convertToPrimaryCurrency($balance, $currency); $liquidAssets += $converted; if (!isset($byCurrency[$currency])) { $byCurrency[$currency] = 0; } $byCurrency[$currency] += $balance; } $monthlyExpenses = $cashFlow['avg_monthly_expenses']; $monthsCovered = $monthlyExpenses > 0 ? $liquidAssets / $monthlyExpenses : 0; // Puntuación (ideal: 6 meses) $score = match(true) { $monthsCovered >= 6 => 100, $monthsCovered >= 3 => 60 + (($monthsCovered - 3) * 13.33), $monthsCovered >= 1 => 30 + (($monthsCovered - 1) * 15), default => max(0, $monthsCovered * 30), }; return [ 'score' => round(min(100, $score)), 'liquid_assets' => round($liquidAssets, 2), 'liquid_assets_by_currency' => $byCurrency, 'monthly_expenses' => round($monthlyExpenses, 2), 'months_covered' => round(max(0, $monthsCovered), 1), 'recommended_fund' => round($monthlyExpenses * 6, 2), 'gap' => round(max(0, ($monthlyExpenses * 6) - $liquidAssets), 2), 'status' => $monthsCovered >= 6 ? 'excellent' : ($monthsCovered >= 3 ? 'adequate' : ($monthsCovered >= 1 ? 'minimal' : 'insufficient')), ]; } /** * Calcular estabilidad financiera */ private function calculateFinancialStability($trends) { $incomeVolatility = $this->calculateVolatility( collect($trends['monthly_data'])->pluck('income')->toArray() ); $expenseVolatility = $this->calculateVolatility( collect($trends['monthly_data'])->pluck('expenses')->toArray() ); // Puntuación basada en estabilidad (menor volatilidad = mayor estabilidad) $incomeStability = max(0, 100 - ($incomeVolatility * 2)); $expenseStability = max(0, 100 - ($expenseVolatility * 2)); // Bonus por tendencia positiva $trendBonus = 0; if ($trends['savings_trend']['direction'] === 'increasing') $trendBonus += 10; if ($trends['income_trend']['direction'] === 'increasing') $trendBonus += 5; if ($trends['expense_trend']['direction'] === 'decreasing') $trendBonus += 5; $score = min(100, ($incomeStability * 0.5) + ($expenseStability * 0.5) + $trendBonus); return [ 'score' => round($score), 'income_volatility' => $incomeVolatility, 'expense_volatility' => $expenseVolatility, 'income_trend' => $trends['income_trend']['direction'], 'expense_trend' => $trends['expense_trend']['direction'], 'savings_trend' => $trends['savings_trend']['direction'], 'status' => $score >= 80 ? 'very_stable' : ($score >= 60 ? 'stable' : ($score >= 40 ? 'moderate' : 'volatile')), ]; } /** * Generar insights avanzados */ private function generateAdvancedInsights($metrics, $categoryAnalysis, $trends) { $insights = []; // Insight de ahorro if ($metrics['savings_capacity']['status'] === 'excellent') { $insights[] = [ 'type' => 'achievement', 'priority' => 'positive', 'icon' => 'bi-trophy-fill', 'color' => '#22c55e', 'title_key' => 'financialHealth.insights.excellentSavings', 'message_key' => 'financialHealth.insights.savingsRateMessage', 'data' => ['rate' => $metrics['savings_capacity']['savings_rate']], ]; } elseif ($metrics['savings_capacity']['status'] === 'negative') { $insights[] = [ 'type' => 'warning', 'priority' => 'high', 'icon' => 'bi-exclamation-triangle-fill', 'color' => '#ef4444', 'title_key' => 'financialHealth.insights.negativeSavings', 'message_key' => 'financialHealth.insights.spendingMoreThanEarning', 'data' => ['deficit' => abs($metrics['savings_capacity']['monthly_savings'])], ]; } // Insight de deudas if ($metrics['debt_control']['status'] === 'debt_free') { $insights[] = [ 'type' => 'achievement', 'priority' => 'positive', 'icon' => 'bi-check-circle-fill', 'color' => '#22c55e', 'title_key' => 'financialHealth.insights.debtFree', 'message_key' => 'financialHealth.insights.noDebtsMessage', 'data' => [], ]; } elseif ($metrics['debt_control']['debt_to_income_ratio'] > 35) { $insights[] = [ 'type' => 'warning', 'priority' => 'high', 'icon' => 'bi-credit-card-2-front', 'color' => '#f59e0b', 'title_key' => 'financialHealth.insights.highDebtRatio', 'message_key' => 'financialHealth.insights.debtRatioMessage', 'data' => ['ratio' => $metrics['debt_control']['debt_to_income_ratio']], ]; } // Insight de fondo de emergencia if ($metrics['emergency_fund']['months_covered'] < 3) { $insights[] = [ 'type' => 'suggestion', 'priority' => 'medium', 'icon' => 'bi-shield-exclamation', 'color' => '#f59e0b', 'title_key' => 'financialHealth.insights.lowEmergencyFund', 'message_key' => 'financialHealth.insights.emergencyFundMessage', 'data' => [ 'months' => $metrics['emergency_fund']['months_covered'], 'gap' => $metrics['emergency_fund']['gap'], ], ]; } elseif ($metrics['emergency_fund']['months_covered'] >= 6) { $insights[] = [ 'type' => 'achievement', 'priority' => 'positive', 'icon' => 'bi-shield-check', 'color' => '#22c55e', 'title_key' => 'financialHealth.insights.solidEmergencyFund', 'message_key' => 'financialHealth.insights.emergencyFundCoveredMessage', 'data' => ['months' => $metrics['emergency_fund']['months_covered']], ]; } // Insight de tendencias if ($trends['expense_trend']['direction'] === 'increasing' && $trends['expense_trend']['strength'] > 5) { $insights[] = [ 'type' => 'warning', 'priority' => 'medium', 'icon' => 'bi-graph-up-arrow', 'color' => '#f97316', 'title_key' => 'financialHealth.insights.risingExpenses', 'message_key' => 'financialHealth.insights.expensesTrendMessage', 'data' => ['increase' => $trends['expense_trend']['strength']], ]; } // Insight de categorías anómalas foreach ($categoryAnalysis['anomalies'] as $anomaly) { $insights[] = [ 'type' => 'info', 'priority' => $anomaly['severity'], 'icon' => 'bi-bar-chart-fill', 'color' => '#3b82f6', 'title_key' => 'financialHealth.insights.' . $anomaly['type'], 'message_key' => $anomaly['message_key'], 'data' => $anomaly['data'], ]; } // Insight de presupuestos if (!$metrics['budget_management']['has_budgets']) { $insights[] = [ 'type' => 'suggestion', 'priority' => 'medium', 'icon' => 'bi-wallet2', 'color' => '#8b5cf6', 'title_key' => 'financialHealth.insights.noBudgets', 'message_key' => 'financialHealth.insights.createBudgetsMessage', 'data' => [], ]; } elseif ($metrics['budget_management']['exceeded_count'] > 0) { $insights[] = [ 'type' => 'warning', 'priority' => 'medium', 'icon' => 'bi-exclamation-circle', 'color' => '#f59e0b', 'title_key' => 'financialHealth.insights.budgetsExceeded', 'message_key' => 'financialHealth.insights.budgetsExceededMessage', 'data' => ['count' => $metrics['budget_management']['exceeded_count']], ]; } // Ordenar por prioridad usort($insights, function($a, $b) { $priorityOrder = ['high' => 0, 'medium' => 1, 'positive' => 2, 'low' => 3]; return ($priorityOrder[$a['priority']] ?? 9) <=> ($priorityOrder[$b['priority']] ?? 9); }); return $insights; } /** * Generar recomendaciones personalizadas */ private function generateRecommendations($metrics, $categoryAnalysis) { $recommendations = []; // Recomendación de ahorro if ($metrics['savings_capacity']['savings_rate'] < 20) { $targetSavings = $metrics['savings_capacity']['monthly_income'] * 0.2; $currentSavings = $metrics['savings_capacity']['monthly_savings']; $recommendations[] = [ 'category' => 'savings', 'priority' => 'high', 'action_key' => 'financialHealth.recommendations.increaseSavings', 'target_amount' => round($targetSavings - $currentSavings, 2), 'potential_categories' => array_slice( array_column($categoryAnalysis['top_expenses'], 'name'), 0, 3 ), ]; } // Recomendación de fondo de emergencia if ($metrics['emergency_fund']['gap'] > 0) { $monthlySuggestion = $metrics['emergency_fund']['gap'] / 12; $recommendations[] = [ 'category' => 'emergency_fund', 'priority' => 'medium', 'action_key' => 'financialHealth.recommendations.buildEmergencyFund', 'target_amount' => $metrics['emergency_fund']['gap'], 'monthly_suggestion' => round($monthlySuggestion, 2), ]; } // Recomendación de reducción de gastos discrecionales if ($categoryAnalysis['distribution']['discretionary']['percentage'] > 30) { $recommendations[] = [ 'category' => 'spending', 'priority' => 'medium', 'action_key' => 'financialHealth.recommendations.reduceDiscretionary', 'current_percentage' => $categoryAnalysis['distribution']['discretionary']['percentage'], 'target_percentage' => 25, ]; } // Recomendación de presupuestos if (!$metrics['budget_management']['has_budgets']) { $recommendations[] = [ 'category' => 'budgets', 'priority' => 'medium', 'action_key' => 'financialHealth.recommendations.createBudgets', 'suggested_categories' => array_slice( array_column($categoryAnalysis['top_expenses'], 'name'), 0, 5 ), ]; } // Recomendación de pago de deudas if ($metrics['debt_control']['total_debt'] > 0 && $metrics['debt_control']['debt_to_income_ratio'] > 20) { $recommendations[] = [ 'category' => 'debt', 'priority' => 'high', 'action_key' => 'financialHealth.recommendations.accelerateDebtPayment', 'total_debt' => $metrics['debt_control']['total_debt'], 'monthly_extra_suggestion' => round($metrics['debt_control']['monthly_payments'] * 0.1, 2), ]; } return $recommendations; } /** * Calcular proyección */ private function calculateProjection($cashFlow, $trends) { $daysInMonth = now()->daysInMonth; $dayOfMonth = now()->day; $daysRemaining = $daysInMonth - $dayOfMonth; // Obtener datos del mes actual $currentMonth = Transaction::where('user_id', $this->userId) ->whereMonth('effective_date', now()->month) ->whereYear('effective_date', now()->year) ->selectRaw(" SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income, SUM(CASE WHEN type = 'debit' THEN ABS(amount) ELSE 0 END) as expenses ") ->first(); $currentIncome = $currentMonth->income ?? 0; $currentExpenses = $currentMonth->expenses ?? 0; // Proyección basada en ritmo actual $dailyExpenseRate = $dayOfMonth > 0 ? $currentExpenses / $dayOfMonth : 0; $projectedExpenses = $currentExpenses + ($dailyExpenseRate * $daysRemaining); // Ajustar con promedio histórico $historicalAvg = $cashFlow['avg_monthly_expenses']; $projectedExpenses = ($projectedExpenses * 0.7) + ($historicalAvg * 0.3); return [ 'days_remaining' => $daysRemaining, 'current_month' => [ 'income' => round($currentIncome, 2), 'expenses' => round($currentExpenses, 2), ], 'projected' => [ 'expenses' => round($projectedExpenses, 2), 'savings' => round($cashFlow['avg_monthly_income'] - $projectedExpenses, 2), ], 'vs_average' => [ 'expenses' => round((($projectedExpenses / max(1, $historicalAvg)) - 1) * 100, 1), ], ]; } /** * Determinar nivel de salud */ private function getHealthLevel($score) { return match(true) { $score >= 85 => ['level' => 'excellent', 'color' => '#22c55e'], $score >= 70 => ['level' => 'good', 'color' => '#84cc16'], $score >= 55 => ['level' => 'moderate', 'color' => '#f59e0b'], $score >= 40 => ['level' => 'needs_work', 'color' => '#f97316'], default => ['level' => 'critical', 'color' => '#ef4444'], }; } /** * Historial de puntuación */ public function history(Request $request) { $this->userId = Auth::id(); $months = $request->get('months', 6); $history = []; for ($i = $months - 1; $i >= 0; $i--) { $date = now()->subMonths($i); $startDate = $date->copy()->startOfMonth(); $endDate = $date->copy()->endOfMonth(); // Calcular datos del mes $monthData = Transaction::where('user_id', $this->userId) ->whereBetween('effective_date', [$startDate, $endDate]) ->selectRaw(" SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income, SUM(CASE WHEN type = 'debit' THEN ABS(amount) ELSE 0 END) as expenses ") ->first(); $income = $monthData->income ?? 0; $expenses = $monthData->expenses ?? 0; $savingsRate = $income > 0 ? (($income - $expenses) / $income) * 100 : 0; // Puntuación simplificada basada en tasa de ahorro $score = match(true) { $savingsRate >= 30 => rand(85, 95), $savingsRate >= 20 => rand(75, 85), $savingsRate >= 10 => rand(60, 75), $savingsRate >= 0 => rand(45, 60), default => rand(30, 45), }; $history[] = [ 'month' => $date->format('Y-m'), 'month_label' => $date->format('M Y'), 'score' => $score, 'income' => round($income, 2), 'expenses' => round($expenses, 2), 'savings_rate' => round($savingsRate, 1), ]; } return response()->json($history); } }