diff --git a/CHANGELOG.md b/CHANGELOG.md index 4779a93..1e071f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/). Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/). +## [1.32.1] - 2025-12-14 + +### Fixed +- **Reportes por Categoría** - Corregido bug de agrupación que mostraba totales incorrectos + - El total de "Garagem" mostraba 3062.88€ (todo "Transporte") en lugar de 1201.25€ + - La query agrupaba por parent_id pero mostraba nombre de subcategoría + - Ahora cada subcategoría muestra su total individual correcto + - Añadido parámetro opcional `group_by_parent=true` para agrupar por categoría padre + +- **Endpoint `/api/reports/overdue`** - Corregido error 500 + - Cambiado `li.amount` a `li.installment_amount` (nombre correcto de la columna) + - Añadida condición `deleted_at IS NULL` para excluir registros eliminados + +### Changed +- **Servidor actualizado** - Aplicadas 12 actualizaciones de seguridad + - Kernel Linux 6.8.0-90 + - Nginx 1.29.4 + - PHP-common actualizado + - AppArmor 4.0.1 + +- **Actualizaciones automáticas configuradas** + - Unattended-upgrades habilitado + - Actualizaciones semanales (cada 7 días) + - Auto-limpieza cada 30 días + - Incluye: Ubuntu security, updates, Nginx, PHP PPA + + ## [1.32.0] - 2025-12-14 ### Added diff --git a/VERSION b/VERSION index 359c410..96cd6ee 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.32.0 +1.32.1 diff --git a/backend/app/Http/Controllers/Api/BudgetController.php b/backend/app/Http/Controllers/Api/BudgetController.php index 5f66482..208b819 100644 --- a/backend/app/Http/Controllers/Api/BudgetController.php +++ b/backend/app/Http/Controllers/Api/BudgetController.php @@ -47,7 +47,7 @@ public function index(Request $request) } /** - * Crear un presupuesto + * Crear un presupuesto (con propagación automática a meses futuros) */ public function store(Request $request) { @@ -77,10 +77,46 @@ public function store(Request $request) $validated['user_id'] = Auth::id(); + // Crear el presupuesto del mes actual $budget = Budget::create($validated); + // Propagar automáticamente a los 12 meses siguientes + $currentYear = $validated['year']; + $currentMonth = $validated['month']; + + for ($i = 1; $i <= 12; $i++) { + $nextMonth = $currentMonth + $i; + $nextYear = $currentYear; + + if ($nextMonth > 12) { + $nextMonth -= 12; + $nextYear++; + } + + // Solo crear si no existe + $existsNext = Budget::forUser(Auth::id()) + ->where('category_id', $validated['category_id']) + ->where('year', $nextYear) + ->where('month', $nextMonth) + ->exists(); + + if (!$existsNext) { + Budget::create([ + 'user_id' => Auth::id(), + 'category_id' => $validated['category_id'], + 'name' => $validated['name'] ?? null, + 'amount' => $validated['amount'], + 'currency' => $validated['currency'] ?? null, + 'year' => $nextYear, + 'month' => $nextMonth, + 'period_type' => $validated['period_type'] ?? 'monthly', + 'notes' => $validated['notes'] ?? null, + ]); + } + } + return response()->json([ - 'message' => 'Presupuesto creado', + 'message' => 'Presupuesto creado y propagado', 'data' => $budget->load('category'), ], 201); } @@ -140,15 +176,30 @@ public function update(Request $request, $id) } /** - * Eliminar un presupuesto + * Eliminar un presupuesto (y de meses futuros) */ public function destroy($id) { $budget = Budget::forUser(Auth::id())->findOrFail($id); - $budget->delete(); + + $categoryId = $budget->category_id; + $year = $budget->year; + $month = $budget->month; + + // Eliminar este y todos los futuros de la misma categoría + Budget::forUser(Auth::id()) + ->where('category_id', $categoryId) + ->where(function($q) use ($year, $month) { + $q->where('year', '>', $year) + ->orWhere(function($q2) use ($year, $month) { + $q2->where('year', $year) + ->where('month', '>=', $month); + }); + }) + ->delete(); return response()->json([ - 'message' => 'Presupuesto eliminado', + 'message' => 'Presupuesto eliminado (incluyendo meses futuros)', ]); } @@ -184,16 +235,16 @@ public function availableCategories(Request $request) $year = $request->get('year', now()->year); $month = $request->get('month', now()->month); - // Obtener categorías de gasto (debit) del usuario + // Obtener IDs de categorías ya usadas en el período $usedCategoryIds = Budget::forUser(Auth::id()) ->forPeriod($year, $month) ->pluck('category_id') ->toArray(); - // Categorías padre con tipo debit + // Categorías padre con tipo expense o both (gastos) $categories = Category::where('user_id', Auth::id()) ->whereNull('parent_id') - ->where('type', 'debit') + ->whereIn('type', ['expense', 'both']) ->whereNotIn('id', $usedCategoryIds) ->orderBy('name') ->get(); @@ -222,20 +273,13 @@ public function yearSummary(Request $request) $monthlyData[] = [ 'month' => $month, 'month_name' => Carbon::createFromDate($year, $month, 1)->format('M'), - 'budget' => $totalBudget, - 'spent' => $totalSpent, - 'remaining' => $totalBudget - $totalSpent, - 'usage_percentage' => $totalBudget > 0 ? round(($totalSpent / $totalBudget) * 100, 1) : 0, + 'budgeted' => round($totalBudget, 2), + 'spent' => round($totalSpent, 2), + 'remaining' => round($totalBudget - $totalSpent, 2), + 'percentage' => $totalBudget > 0 ? round(($totalSpent / $totalBudget) * 100, 1) : 0, ]; } - return response()->json([ - 'year' => $year, - 'monthly' => $monthlyData, - 'totals' => [ - 'budget' => array_sum(array_column($monthlyData, 'budget')), - 'spent' => array_sum(array_column($monthlyData, 'spent')), - ], - ]); + return response()->json($monthlyData); } } diff --git a/backend/app/Http/Controllers/Api/FinancialHealthController.php b/backend/app/Http/Controllers/Api/FinancialHealthController.php index ce3142b..c337aeb 100644 --- a/backend/app/Http/Controllers/Api/FinancialHealthController.php +++ b/backend/app/Http/Controllers/Api/FinancialHealthController.php @@ -9,96 +9,804 @@ use App\Models\Budget; use App\Models\FinancialGoal; use App\Models\RecurringTemplate; +use App\Models\Category; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Carbon\Carbon; class FinancialHealthController extends Controller { + private $userId; + private $primaryCurrency; + private $exchangeRates; + /** * Obtener puntuación de salud financiera completa */ public function index(Request $request) { - $userId = Auth::id(); + $this->userId = Auth::id(); + $this->setPrimaryCurrency(); + $this->loadExchangeRates(); + + // Obtener datos base + $financialSummary = $this->getFinancialSummary(); + $cashFlowAnalysis = $this->analyzeCashFlow(); + $categoryAnalysis = $this->analyzeCategories(); + $trendAnalysis = $this->analyzeTrends(); - // Calcular cada métrica - $savingsCapacity = $this->calculateSavingsCapacity($userId); - $debtControl = $this->calculateDebtControl($userId); - $budgetManagement = $this->calculateBudgetManagement($userId); - $investments = $this->calculateInvestments($userId); - $emergencyFund = $this->calculateEmergencyFund($userId); - $futurePlanning = $this->calculateFuturePlanning($userId); - - // Puntuación general ponderada - $weights = [ - 'savings' => 0.25, - 'debt' => 0.20, - 'budget' => 0.15, - 'investments' => 0.15, - 'emergency' => 0.15, - 'planning' => 0.10, + // 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), ]; - $overallScore = round( - ($savingsCapacity['score'] * $weights['savings']) + - ($debtControl['score'] * $weights['debt']) + - ($budgetManagement['score'] * $weights['budget']) + - ($investments['score'] * $weights['investments']) + - ($emergencyFund['score'] * $weights['emergency']) + - ($futurePlanning['score'] * $weights['planning']) - ); + // 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 y recomendaciones - $insights = $this->generateInsights($userId, [ - 'savings' => $savingsCapacity, - 'debt' => $debtControl, - 'budget' => $budgetManagement, - 'investments' => $investments, - 'emergency' => $emergencyFund, - 'planning' => $futurePlanning, - ]); + // 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()->format('Y-m-d'), - 'metrics' => [ - 'savings_capacity' => $savingsCapacity, - 'debt_control' => $debtControl, - 'budget_management' => $budgetManagement, - 'investments' => $investments, - 'emergency_fund' => $emergencyFund, - 'future_planning' => $futurePlanning, + 'last_updated' => now()->toIso8601String(), + 'currency' => $this->primaryCurrency, + + // 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), ]); } + /** + * 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($userId) + private function calculateSavingsCapacity($cashFlow) { - // Últimos 3 meses - $data = Transaction::where('user_id', $userId) - ->where('effective_date', '>=', now()->subMonths(3)->startOfMonth()) - ->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 expense - ") - ->first(); - - $income = $data->income ?? 0; - $expense = $data->expense ?? 0; - $savings = $income - $expense; - $savingsRate = $income > 0 ? ($savings / $income) * 100 : 0; + $savingsRate = $cashFlow['savings_rate']; // Puntuación basada en tasa de ahorro - // 0-10% = 40, 10-20% = 60, 20-30% = 80, 30%+ = 100 $score = match(true) { $savingsRate >= 30 => 100, $savingsRate >= 20 => 80 + (($savingsRate - 20) * 2), @@ -109,80 +817,107 @@ private function calculateSavingsCapacity($userId) return [ 'score' => round(min(100, max(0, $score))), - 'savings_rate' => round($savingsRate, 1), - 'monthly_savings' => round($savings / 3, 2), - 'status' => $savingsRate >= 20 ? 'excellent' : ($savingsRate >= 10 ? 'good' : 'needs_improvement'), - 'message' => $savingsRate >= 20 - ? "¡Excelente! Ahorras el {$savingsRate}% de tus ingresos" - : ($savingsRate >= 10 - ? "Buen trabajo. Ahorras el {$savingsRate}%" - : "Intenta aumentar tu tasa de ahorro"), + '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 + * Calcular control de deudas con soporte multi-divisa */ - private function calculateDebtControl($userId) + private function calculateDebtControl($cashFlow) { - // Obtener deudas activas - $liabilities = LiabilityAccount::where('user_id', $userId) + $liabilities = LiabilityAccount::where('user_id', $this->userId) ->where('status', 'active') ->get(); - $totalDebt = $liabilities->sum('current_balance'); - $totalCreditLimit = $liabilities->sum('credit_limit'); - $monthlyPayments = $liabilities->sum('monthly_payment'); + // Convertir todas las deudas a moneda principal + $totalDebt = 0; + $totalCreditLimit = 0; + $monthlyPayments = 0; + $debtByCurrency = []; - // Ingresos mensuales promedio - $monthlyIncome = Transaction::where('user_id', $userId) - ->where('type', 'credit') - ->where('effective_date', '>=', now()->subMonths(3)->startOfMonth()) - ->sum('amount') / 3; + 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; + } - // Ratio deuda/ingresos + $totalCreditLimit = $totalCreditLimit ?: 1; + $monthlyIncome = $cashFlow['avg_monthly_income']; + + // Ratios $debtToIncomeRatio = $monthlyIncome > 0 ? ($monthlyPayments / $monthlyIncome) * 100 : 0; - - // Utilización de crédito - $creditUtilization = $totalCreditLimit > 0 ? ($totalDebt / $totalCreditLimit) * 100 : 0; + $creditUtilization = ($totalDebt / $totalCreditLimit) * 100; // Puntuación - // DTI < 20% = excelente, 20-35% = bueno, 35-50% = moderado, >50% = alto riesgo - $score = match(true) { - $debtToIncomeRatio <= 20 => 100 - ($debtToIncomeRatio * 0.5), - $debtToIncomeRatio <= 35 => 80 - (($debtToIncomeRatio - 20) * 1.3), - $debtToIncomeRatio <= 50 => 60 - (($debtToIncomeRatio - 35) * 2), - default => max(0, 30 - (($debtToIncomeRatio - 50) * 0.6)), - }; + $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(min(100, max(0, $score))), + '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(), - 'status' => $debtToIncomeRatio <= 20 ? 'excellent' : ($debtToIncomeRatio <= 35 ? 'acceptable' : 'needs_attention'), - 'message' => $totalDebt == 0 - ? "¡Sin deudas! Excelente situación" - : ($debtToIncomeRatio <= 35 - ? "Nivel aceptable. Crédito disponible: " . number_format($totalCreditLimit - $totalDebt, 2) . "€" - : "Considera reducir tus deudas"), + '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($userId) + private function calculateBudgetManagement() { $currentMonth = now()->month; $currentYear = now()->year; - $budgets = Budget::where('user_id', $userId) + $budgets = Budget::where('user_id', $this->userId) ->where('year', $currentYear) ->where('month', $currentMonth) ->where('is_active', true) + ->with('category') ->get(); if ($budgets->isEmpty()) { @@ -190,116 +925,137 @@ private function calculateBudgetManagement($userId) 'score' => 50, 'has_budgets' => false, 'status' => 'not_configured', - 'message' => 'No tienes presupuestos configurados', - 'exceeded_count' => 0, 'total_budgets' => 0, + 'exceeded_count' => 0, + 'compliance_rate' => 0, ]; } - $exceededCount = $budgets->filter(fn($b) => $b->is_exceeded)->count(); - $totalBudgets = $budgets->count(); - $complianceRate = (($totalBudgets - $exceededCount) / $totalBudgets) * 100; + $exceededCount = 0; + $totalUsage = 0; + $budgetDetails = []; - // También verificar el uso promedio - $avgUsage = $budgets->avg('usage_percentage'); + 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, + $exceededCount == 1 => 70, + $exceededCount == 2 => 55, default => max(20, 55 - ($exceededCount * 10)), }; - // Encontrar categoría más excedida - $mostExceeded = $budgets->filter(fn($b) => $b->is_exceeded) - ->sortByDesc('usage_percentage') - ->first(); - return [ 'score' => round($score), 'has_budgets' => true, - 'total_budgets' => $totalBudgets, + 'total_budgets' => $budgets->count(), 'exceeded_count' => $exceededCount, 'compliance_rate' => round($complianceRate, 1), 'avg_usage' => round($avgUsage, 1), - 'most_exceeded' => $mostExceeded ? [ - 'category' => $mostExceeded->category?->name ?? $mostExceeded->name, - 'usage' => $mostExceeded->usage_percentage, - ] : null, + 'budgets' => $budgetDetails, 'status' => $exceededCount == 0 ? 'on_track' : ($exceededCount <= 2 ? 'needs_attention' : 'exceeded'), - 'message' => $exceededCount == 0 - ? "Todos los presupuestos bajo control" - : "¡Atención! Excediste presupuesto en " . ($mostExceeded->category?->name ?? 'una categoría'), ]; } /** - * Calcular inversiones + * Obtener gasto de un presupuesto con soporte multi-divisa */ - private function calculateInvestments($userId) + private function getBudgetSpent($budget) { - // Cuentas de tipo inversión - $investmentAccounts = Account::where('user_id', $userId) - ->where('type', 'investment') - ->get(); + $startDate = Carbon::createFromDate($budget->year, $budget->month, 1)->startOfMonth(); + $endDate = $startDate->copy()->endOfMonth(); - $totalInvestments = $investmentAccounts->sum('current_balance'); + // 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')]); - // Total de activos - $totalAssets = Account::where('user_id', $userId) - ->where('include_in_total', true) - ->sum('current_balance'); + // Sumar con conversión de moneda + $total = 0; + foreach ($transactions as $t) { + $amount = abs($t->amount); + $total += $this->convertToPrimaryCurrency($amount, $t->currency); + } - $investmentRatio = $totalAssets > 0 ? ($totalInvestments / $totalAssets) * 100 : 0; + return $total; + } + + /** + * Calcular eficiencia de gastos + */ + private function calculateExpenseEfficiency($categoryAnalysis) + { + $distribution = $categoryAnalysis['distribution']; - // Puntuación basada en ratio de inversión - $score = match(true) { - $investmentRatio >= 30 => 100, - $investmentRatio >= 20 => 80 + (($investmentRatio - 20) * 2), - $investmentRatio >= 10 => 60 + (($investmentRatio - 10) * 2), - $investmentRatio >= 5 => 40 + (($investmentRatio - 5) * 4), - default => max(20, $investmentRatio * 8), - }; + // 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(min(100, $score)), - 'total_investments' => round($totalInvestments, 2), - 'investment_ratio' => round($investmentRatio, 1), - 'accounts_count' => $investmentAccounts->count(), - 'status' => $investmentRatio >= 20 ? 'good' : ($investmentRatio >= 10 ? 'moderate' : 'low'), - 'message' => $investmentRatio >= 20 - ? "Buena diversificación. Sigue así" - : "Considera aumentar tu inversión", + '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 + * Calcular fondo de emergencia con soporte multi-divisa */ - private function calculateEmergencyFund($userId) + private function calculateEmergencyFund($cashFlow) { - // Cuentas líquidas (checking, savings, cash) - $liquidAccounts = Account::where('user_id', $userId) + // Obtener cuentas líquidas con su moneda + $accounts = Account::where('user_id', $this->userId) ->whereIn('type', ['checking', 'savings', 'cash']) ->where('include_in_total', true) - ->sum('current_balance'); + ->get(['current_balance', 'currency']); - // Gastos mensuales promedio - $monthlyExpenses = Transaction::where('user_id', $userId) - ->where('type', 'debit') - ->where('effective_date', '>=', now()->subMonths(3)->startOfMonth()) - ->sum(fn($t) => abs($t->amount)) / 3; + // 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; + } - // Usar consulta directa para mejor rendimiento - $monthlyExpenses = abs(Transaction::where('user_id', $userId) - ->where('type', 'debit') - ->where('effective_date', '>=', now()->subMonths(3)->startOfMonth()) - ->sum('amount')) / 3; - - // Meses cubiertos - $monthsCovered = $monthlyExpenses > 0 ? $liquidAccounts / $monthlyExpenses : 0; + $monthlyExpenses = $cashFlow['avg_monthly_expenses']; + $monthsCovered = $monthlyExpenses > 0 ? $liquidAssets / $monthlyExpenses : 0; // Puntuación (ideal: 6 meses) $score = match(true) { @@ -311,63 +1067,304 @@ private function calculateEmergencyFund($userId) return [ 'score' => round(min(100, $score)), - 'liquid_assets' => round($liquidAccounts, 2), + 'liquid_assets' => round($liquidAssets, 2), + 'liquid_assets_by_currency' => $byCurrency, 'monthly_expenses' => round($monthlyExpenses, 2), - 'months_covered' => round($monthsCovered, 1), + 'months_covered' => round(max(0, $monthsCovered), 1), 'recommended_fund' => round($monthlyExpenses * 6, 2), - 'status' => $monthsCovered >= 6 ? 'excellent' : ($monthsCovered >= 3 ? 'moderate' : 'insufficient'), - 'message' => $monthsCovered >= 6 - ? "Excelente fondo de emergencia" - : "Tienes " . number_format($liquidAccounts, 2) . "€ (" . round($monthsCovered, 1) . " meses de gastos)", + 'gap' => round(max(0, ($monthlyExpenses * 6) - $liquidAssets), 2), + 'status' => $monthsCovered >= 6 ? 'excellent' : ($monthsCovered >= 3 ? 'adequate' : ($monthsCovered >= 1 ? 'minimal' : 'insufficient')), ]; } /** - * Calcular planificación futura + * Calcular estabilidad financiera */ - private function calculateFuturePlanning($userId) + private function calculateFinancialStability($trends) { - // Metas financieras activas - $goals = FinancialGoal::where('user_id', $userId) - ->where('status', 'active') - ->get(); + $incomeVolatility = $this->calculateVolatility( + collect($trends['monthly_data'])->pluck('income')->toArray() + ); + $expenseVolatility = $this->calculateVolatility( + collect($trends['monthly_data'])->pluck('expenses')->toArray() + ); - // Transacciones recurrentes configuradas - $recurringCount = RecurringTemplate::where('user_id', $userId) - ->where('is_active', true) - ->count(); + // Puntuación basada en estabilidad (menor volatilidad = mayor estabilidad) + $incomeStability = max(0, 100 - ($incomeVolatility * 2)); + $expenseStability = max(0, 100 - ($expenseVolatility * 2)); - // Presupuestos configurados - $hasBudgets = Budget::where('user_id', $userId) - ->where('is_active', true) - ->exists(); + // 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; - $activeGoals = $goals->count(); - $goalsOnTrack = $goals->filter(fn($g) => $g->is_on_track)->count(); - $avgProgress = $goals->avg('progress_percentage') ?? 0; - - // Puntuación - $baseScore = 40; - if ($activeGoals > 0) $baseScore += 20; - if ($activeGoals >= 3) $baseScore += 10; - if ($goalsOnTrack > 0) $baseScore += 15; - if ($hasBudgets) $baseScore += 10; - if ($recurringCount >= 3) $baseScore += 5; - - // Bonus por progreso - $baseScore += min(10, $avgProgress / 10); + $score = min(100, ($incomeStability * 0.5) + ($expenseStability * 0.5) + $trendBonus); return [ - 'score' => round(min(100, $baseScore)), - 'active_goals' => $activeGoals, - 'goals_on_track' => $goalsOnTrack, - 'avg_progress' => round($avgProgress, 1), - 'has_budgets' => $hasBudgets, - 'recurring_configured' => $recurringCount, - 'status' => $activeGoals >= 3 ? 'excellent' : ($activeGoals >= 1 ? 'good' : 'needs_setup'), - 'message' => $activeGoals > 0 - ? "¡Muy bien! Tienes $activeGoals metas activas en progreso" - : "Crea metas financieras para mejorar tu planificación", + '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), + ], ]; } @@ -377,119 +1374,58 @@ private function calculateFuturePlanning($userId) private function getHealthLevel($score) { return match(true) { - $score >= 85 => ['level' => 'excellent', 'label' => 'Excelente Salud', 'color' => '#22c55e'], - $score >= 70 => ['level' => 'good', 'label' => 'Buena Salud', 'color' => '#84cc16'], - $score >= 55 => ['level' => 'moderate', 'label' => 'Salud Moderada', 'color' => '#f59e0b'], - $score >= 40 => ['level' => 'needs_work', 'label' => 'Necesita Mejorar', 'color' => '#f97316'], - default => ['level' => 'critical', 'label' => 'Atención Necesaria', 'color' => '#ef4444'], + $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'], }; } - /** - * Generar insights y recomendaciones - */ - private function generateInsights($userId, $metrics) - { - $insights = []; - - // Prioridad Alta - Problemas críticos - if ($metrics['debt']['score'] < 50) { - $insights[] = [ - 'type' => 'high_priority', - 'icon' => 'bi-exclamation-triangle-fill', - 'color' => '#ef4444', - 'title' => 'Prioridad Alta', - 'message' => "Reduce gastos en " . ($metrics['budget']['most_exceeded']['category'] ?? 'categorías con exceso') . ". Estás excediendo el presupuesto en " . ($metrics['budget']['most_exceeded']['usage'] ?? 0) . "%.", - ]; - } - - // Prioridad Media - Mejoras recomendadas - if ($metrics['emergency']['months_covered'] < 6) { - $needed = $metrics['emergency']['recommended_fund'] - $metrics['emergency']['liquid_assets']; - $insights[] = [ - 'type' => 'medium_priority', - 'icon' => 'bi-shield-exclamation', - 'color' => '#f59e0b', - 'title' => 'Prioridad Media', - 'message' => "Incrementa tu fondo de emergencia a 6 meses de gastos (" . number_format($metrics['emergency']['recommended_fund'], 0) . "€).", - ]; - } - - // Logros - if ($metrics['savings']['savings_rate'] >= 20) { - $insights[] = [ - 'type' => 'achievement', - 'icon' => 'bi-trophy-fill', - 'color' => '#22c55e', - 'title' => 'Logro Destacado', - 'message' => "¡Excelente ahorro mensual! Mantienes una tasa del " . $metrics['savings']['savings_rate'] . "% por encima de la meta.", - ]; - } - - // Oportunidades - if ($metrics['investments']['investment_ratio'] < 20 && $metrics['savings']['savings_rate'] > 15) { - $insights[] = [ - 'type' => 'opportunity', - 'icon' => 'bi-lightbulb-fill', - 'color' => '#3b82f6', - 'title' => 'Oportunidad', - 'message' => "Considera aumentar tu inversión con el excedente de tus ahorros.", - ]; - } - - // Metas próximas - $upcomingGoals = FinancialGoal::where('user_id', $userId) - ->where('status', 'active') - ->whereNotNull('target_date') - ->where('target_date', '<=', now()->addMonths(6)) - ->orderBy('target_date') - ->first(); - - if ($upcomingGoals) { - $insights[] = [ - 'type' => 'upcoming_goal', - 'icon' => 'bi-calendar-check-fill', - 'color' => '#8b5cf6', - 'title' => 'Meta Próxima', - 'message' => "Faltan " . now()->diffInMonths($upcomingGoals->target_date) . " meses para tu meta de " . $upcomingGoals->name . ". ¡Sigue así!", - ]; - } - - // Sugerencia general - if ($metrics['planning']['active_goals'] < 3) { - $insights[] = [ - 'type' => 'suggestion', - 'icon' => 'bi-gear-fill', - 'color' => '#06b6d4', - 'title' => 'Sugerencia', - 'message' => "Activa ahorro automático del 10% cada vez que recibes tu nómina.", - ]; - } - - return $insights; - } - /** * Historial de puntuación */ public function history(Request $request) { - // Por ahora retornamos datos simulados - // En producción, guardaríamos snapshots diarios/semanales + $this->userId = Auth::id(); $months = $request->get('months', 6); $history = []; - $baseScore = 72; for ($i = $months - 1; $i >= 0; $i--) { $date = now()->subMonths($i); - $variation = rand(-5, 8); - $baseScore = max(30, min(95, $baseScore + $variation)); + $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' => $baseScore, + 'score' => $score, + 'income' => round($income, 2), + 'expenses' => round($expenses, 2), + 'savings_rate' => round($savingsRate, 1), ]; } diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php index d249893..772fb81 100644 --- a/backend/app/Http/Controllers/Api/ReportController.php +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -6,8 +6,11 @@ use App\Models\Transaction; use App\Models\Category; use App\Models\Account; +use App\Models\CostCenter; use App\Models\LiabilityAccount; +use App\Models\LiabilityInstallment; use App\Models\RecurringTemplate; +use App\Models\RecurringInstance; use App\Models\Budget; use App\Models\FinancialGoal; use Illuminate\Http\Request; @@ -17,237 +20,502 @@ class ReportController extends Controller { + private $userId; + private $primaryCurrency; + private $exchangeRates; + + private function init() + { + $this->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 + * Resumen general de reportes con multi-divisa */ public function summary(Request $request) { - $userId = Auth::id(); + $this->init(); $year = $request->get('year', now()->year); - // Ingresos y gastos del año - $yearData = Transaction::where('user_id', $userId) - ->whereYear('effective_date', $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 expense - ") - ->first(); + // 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]); - // Comparar con año anterior - $lastYearData = Transaction::where('user_id', $userId) - ->whereYear('effective_date', $year - 1) - ->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 expense - ") - ->first(); + $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' => $yearData->income ?? 0, - 'expense' => $yearData->expense ?? 0, - 'balance' => ($yearData->income ?? 0) - ($yearData->expense ?? 0), + 'income' => round($currentIncome, 2), + 'expense' => round($currentExpense, 2), + 'balance' => round($currentIncome - $currentExpense, 2), ], 'previous' => [ - 'income' => $lastYearData->income ?? 0, - 'expense' => $lastYearData->expense ?? 0, - 'balance' => ($lastYearData->income ?? 0) - ($lastYearData->expense ?? 0), + 'income' => round($previousIncome, 2), + 'expense' => round($previousExpense, 2), + 'balance' => round($previousIncome - $previousExpense, 2), ], 'variation' => [ - 'income' => $lastYearData->income > 0 - ? round((($yearData->income - $lastYearData->income) / $lastYearData->income) * 100, 1) - : 0, - 'expense' => $lastYearData->expense > 0 - ? round((($yearData->expense - $lastYearData->expense) / $lastYearData->expense) * 100, 1) - : 0, + '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 + * Reporte por categorías con multi-divisa */ public function byCategory(Request $request) { - $userId = Auth::id(); + $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'); // debit o credit + $type = $request->get('type', 'debit'); + $groupByParent = $request->get('group_by_parent', false); - $data = Transaction::where('user_id', $userId) - ->whereBetween('effective_date', [$startDate, $endDate]) - ->where('type', $type) - ->whereNotNull('category_id') - ->join('categories', 'transactions.category_id', '=', 'categories.id') - ->selectRaw(" - COALESCE(categories.parent_id, categories.id) as category_group_id, - SUM(ABS(transactions.amount)) as total - ") - ->groupBy('category_group_id') - ->orderByDesc('total') - ->get(); + // 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]); + } - // Obtener nombres de categorías - $categoryIds = $data->pluck('category_group_id')->unique(); - $categories = Category::whereIn('id', $categoryIds)->get()->keyBy('id'); + // 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); + } - $result = $data->map(function($item) use ($categories) { - $category = $categories->get($item->category_group_id); - return [ - 'category_id' => $item->category_group_id, - 'category_name' => $category ? $category->name : 'Sin categoría', - 'icon' => $category ? $category->icon : 'bi-tag', - 'color' => $category ? $category->color : '#6b7280', - 'total' => round($item->total, 2), - ]; - }); + // Ordenar y calcular porcentajes + usort($byCategory, fn($a, $b) => $b['total_converted'] <=> $a['total_converted']); + $grandTotal = array_sum(array_column($byCategory, 'total_converted')); - $grandTotal = $result->sum('total'); - - // Añadir porcentajes - $result = $result->map(function($item) use ($grandTotal) { - $item['percentage'] = $grandTotal > 0 ? round(($item['total'] / $grandTotal) * 100, 1) : 0; - return $item; - }); + $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' => $result->values(), - 'total' => $grandTotal, - 'period' => [ - 'start' => $startDate, - 'end' => $endDate, - ], + 'data' => array_values($result), + 'total' => round($grandTotal, 2), + 'currency' => $this->primaryCurrency, + 'period' => ['start' => $startDate, 'end' => $endDate], ]); } /** - * Reporte de evolución mensual + * 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) { - $userId = Auth::id(); + $this->init(); $months = $request->get('months', 12); + $startDate = now()->subMonths($months)->startOfMonth()->format('Y-m-d'); - $data = Transaction::where('user_id', $userId) - ->where('effective_date', '>=', now()->subMonths($months)->startOfMonth()) - ->selectRaw(" - DATE_FORMAT(effective_date, '%Y-%m') as month, - 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 - ") - ->groupBy('month') - ->orderBy('month') - ->get(); + $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]); - // Calcular balance acumulado y tasa de ahorro - $result = $data->map(function($item) { - $balance = $item->income - $item->expense; - $savingsRate = $item->income > 0 ? round(($balance / $item->income) * 100, 1) : 0; - + // 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')->format('M Y'), - 'income' => round($item->income, 2), - 'expense' => round($item->expense, 2), + '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' => $result, + 'data' => $resultArray, + 'currency' => $this->primaryCurrency, 'averages' => [ - 'income' => round($result->avg('income'), 2), - 'expense' => round($result->avg('expense'), 2), - 'balance' => round($result->avg('balance'), 2), - 'savings_rate' => round($result->avg('savings_rate'), 1), + '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 + * Reporte de gastos por día de la semana con multi-divisa */ public function byDayOfWeek(Request $request) { - $userId = Auth::id(); + $this->init(); $months = $request->get('months', 6); + $startDate = now()->subMonths($months)->format('Y-m-d'); - $data = Transaction::where('user_id', $userId) - ->where('type', 'debit') - ->where('effective_date', '>=', now()->subMonths($months)) - ->selectRaw(" - DAYOFWEEK(effective_date) as day_num, + $data = DB::select(" + SELECT + DAYOFWEEK(t.effective_date) as day_num, + COALESCE(a.currency, 'EUR') as currency, COUNT(*) as count, - SUM(ABS(amount)) as total, - AVG(ABS(amount)) as average - ") - ->groupBy('day_num') - ->orderBy('day_num') - ->get(); + 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]); - $days = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; + // Agrupar por día + $byDay = []; + for ($i = 1; $i <= 7; $i++) { + $byDay[$i] = ['count' => 0, 'total_converted' => 0, 'by_currency' => []]; + } - $result = $data->map(function($item) use ($days) { - return [ - 'day' => $days[$item->day_num - 1], - 'day_num' => $item->day_num, - 'count' => $item->count, - 'total' => round($item->total, 2), - 'average' => round($item->average, 2), + 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($result); + return response()->json([ + 'data' => $result, + 'currency' => $this->primaryCurrency, + ]); } /** - * Top gastos + * Top gastos con multi-divisa */ public function topExpenses(Request $request) { - $userId = Auth::id(); + $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); - $transactions = Transaction::where('user_id', $userId) - ->where('type', 'debit') - ->whereBetween('effective_date', [$startDate, $endDate]) - ->with(['category', 'account']) - ->orderByRaw('ABS(amount) DESC') - ->limit($limit) - ->get(); + $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' => $transactions->map(function($t) { - return [ - 'id' => $t->id, - 'description' => $t->description, - 'amount' => abs($t->amount), - 'date' => $t->effective_date->format('Y-m-d'), - 'category' => $t->category ? $t->category->name : null, - 'account' => $t->account ? $t->account->name : null, - ]; - }), - 'total' => $transactions->sum(fn($t) => abs($t->amount)), + 'data' => $result, + 'total' => round($totalConverted, 2), + 'currency' => $this->primaryCurrency, + 'period' => ['start' => $startDate, 'end' => $endDate], ]); } /** - * Comparativa de períodos + * Comparativa de períodos con multi-divisa */ public function comparePeriods(Request $request) { - $userId = Auth::id(); + $this->init(); $period1Start = $request->get('period1_start'); $period1End = $request->get('period1_end'); $period2Start = $request->get('period2_start'); $period2End = $request->get('period2_end'); - // Si no se especifican, comparar mes actual vs anterior if (!$period1Start) { $period1Start = now()->startOfMonth()->format('Y-m-d'); $period1End = now()->endOfMonth()->format('Y-m-d'); @@ -255,147 +523,219 @@ public function comparePeriods(Request $request) $period2End = now()->subMonth()->endOfMonth()->format('Y-m-d'); } - $getPeriodData = function($start, $end) use ($userId) { - return Transaction::where('user_id', $userId) - ->whereBetween('effective_date', [$start, $end]) - ->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 expense, + $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 - ") - ->first(); + 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, + ]; }; - $period1 = $getPeriodData($period1Start, $period1End); - $period2 = $getPeriodData($period2Start, $period2End); + $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' => [ - 'label' => Carbon::parse($period1Start)->format('M Y'), + 'period1' => array_merge([ + 'label' => Carbon::parse($period1Start)->isoFormat('MMM YYYY'), 'start' => $period1Start, 'end' => $period1End, - 'income' => $period1->income ?? 0, - 'expense' => $period1->expense ?? 0, - 'balance' => ($period1->income ?? 0) - ($period1->expense ?? 0), - 'transactions' => $period1->transactions ?? 0, - ], - 'period2' => [ - 'label' => Carbon::parse($period2Start)->format('M Y'), + ], $period1Data), + 'period2' => array_merge([ + 'label' => Carbon::parse($period2Start)->isoFormat('MMM YYYY'), 'start' => $period2Start, 'end' => $period2End, - 'income' => $period2->income ?? 0, - 'expense' => $period2->expense ?? 0, - 'balance' => ($period2->income ?? 0) - ($period2->expense ?? 0), - 'transactions' => $period2->transactions ?? 0, - ], + ], $period2Data), 'variation' => [ - 'income' => ($period2->income ?? 0) > 0 - ? round(((($period1->income ?? 0) - ($period2->income ?? 0)) / ($period2->income ?? 1)) * 100, 1) - : 0, - 'expense' => ($period2->expense ?? 0) > 0 - ? round(((($period1->expense ?? 0) - ($period2->expense ?? 0)) / ($period2->expense ?? 1)) * 100, 1) - : 0, + 'income' => $calcVariation($period1Data['income'], $period2Data['income']), + 'expense' => $calcVariation($period1Data['expense'], $period2Data['expense']), + 'balance' => $calcVariation($period1Data['balance'], $period2Data['balance']), ], + 'currency' => $this->primaryCurrency, ]); } /** - * Reporte de cuentas + * Reporte de cuentas con multi-divisa */ public function accountsReport(Request $request) { - $userId = Auth::id(); + $this->init(); - $accounts = Account::where('user_id', $userId) - ->withCount('transactions') - ->get(); + $accounts = Account::where('user_id', $this->userId)->get(); - $result = $accounts->map(function($account) { - // Últimas transacciones - $recentActivity = Transaction::where('account_id', $account->id) - ->orderBy('effective_date', 'desc') - ->limit(5) - ->get(['id', 'description', 'amount', 'type', 'effective_date']); - - // Movimientos del mes - $monthStats = Transaction::where('account_id', $account->id) - ->whereMonth('effective_date', now()->month) - ->whereYear('effective_date', now()->year) - ->selectRaw(" + $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 - ") - ->first(); + 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]); - return [ + $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' => $account->current_balance, - 'transactions_count' => $account->transactions_count, - 'month_income' => $monthStats->income ?? 0, - 'month_expense' => $monthStats->expense ?? 0, - 'recent_activity' => $recentActivity, + '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' => $accounts->count(), - 'total_balance' => $accounts->where('include_in_total', true)->sum('current_balance'), + 'total_accounts' => count($accounts), + 'total_balance_converted' => round($totalBalanceConverted, 2), ], ]); } /** - * Proyección de gastos + * Proyección de gastos con multi-divisa */ public function projection(Request $request) { - $userId = Auth::id(); + $this->init(); $months = $request->get('months', 3); + $startDate = now()->subMonths($months)->startOfMonth()->format('Y-m-d'); + $endMonthStart = now()->startOfMonth()->format('Y-m-d'); - // Obtener promedio de los últimos meses - $historical = Transaction::where('user_id', $userId) - ->where('effective_date', '>=', now()->subMonths($months)->startOfMonth()) - ->where('effective_date', '<', now()->startOfMonth()) - ->selectRaw(" - AVG(CASE WHEN type = 'credit' THEN amount ELSE NULL END) as avg_income, - AVG(CASE WHEN type = 'debit' THEN ABS(amount) ELSE NULL END) as avg_expense, - SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) / ? as monthly_income, - SUM(CASE WHEN type = 'debit' THEN ABS(amount) ELSE 0 END) / ? as monthly_expense - ", [$months, $months]) - ->first(); + // 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]); - // Gastos del mes actual - $currentMonth = Transaction::where('user_id', $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 expense - ") - ->first(); + $histIncome = 0; + $histExpense = 0; + foreach ($historical as $row) { + $histIncome += $this->convertToPrimaryCurrency($row->monthly_income, $row->currency); + $histExpense += $this->convertToPrimaryCurrency($row->monthly_expense, $row->currency); + } - // Días transcurridos y restantes - $daysElapsed = now()->day; + // 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; - // Proyección del mes - $projectedExpense = ($currentMonth->expense / $daysElapsed) * $daysInMonth; - $projectedIncome = ($currentMonth->income / $daysElapsed) * $daysInMonth; + $projectedExpense = ($currExpense / $daysElapsed) * $daysInMonth; + $projectedIncome = ($currIncome / $daysElapsed) * $daysInMonth; return response()->json([ 'historical_average' => [ - 'income' => round($historical->monthly_income ?? 0, 2), - 'expense' => round($historical->monthly_expense ?? 0, 2), + 'income' => round($histIncome, 2), + 'expense' => round($histExpense, 2), ], 'current_month' => [ - 'income' => round($currentMonth->income ?? 0, 2), - 'expense' => round($currentMonth->expense ?? 0, 2), + 'income' => round($currIncome, 2), + 'expense' => round($currExpense, 2), 'days_elapsed' => $daysElapsed, 'days_remaining' => $daysRemaining, ], @@ -405,49 +745,383 @@ public function projection(Request $request) 'balance' => round($projectedIncome - $projectedExpense, 2), ], 'vs_average' => [ - 'income' => ($historical->monthly_income ?? 0) > 0 - ? round((($projectedIncome - ($historical->monthly_income ?? 0)) / ($historical->monthly_income ?? 1)) * 100, 1) - : 0, - 'expense' => ($historical->monthly_expense ?? 0) > 0 - ? round((($projectedExpense - ($historical->monthly_expense ?? 0)) / ($historical->monthly_expense ?? 1)) * 100, 1) - : 0, + '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 recurrencias + * Reporte de pasivos/deudas */ - public function recurringReport(Request $request) + public function liabilities(Request $request) { - $userId = Auth::id(); + $this->init(); - $templates = RecurringTemplate::where('user_id', $userId) - ->where('is_active', true) - ->with(['category', 'account']) + $liabilities = LiabilityAccount::where('user_id', $this->userId) + ->with(['installments' => function($q) { + $q->orderBy('due_date'); + }]) ->get(); - $monthlyIncome = $templates->where('type', 'credit')->sum('amount'); - $monthlyExpense = $templates->where('type', 'debit')->sum(fn($t) => abs($t->amount)); + $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([ - 'templates' => $templates->map(function($t) { - return [ - 'id' => $t->id, - 'description' => $t->description, - 'amount' => abs($t->amount), - 'type' => $t->type, - 'frequency' => $t->frequency, - 'category' => $t->category ? $t->category->name : null, - 'next_date' => $t->next_occurrence_date, - ]; - }), + 'data' => $result, + 'currency' => $this->primaryCurrency, 'summary' => [ - 'total_recurring' => $templates->count(), - 'monthly_income' => $monthlyIncome, - 'monthly_expense' => $monthlyExpense, - 'net_recurring' => $monthlyIncome - $monthlyExpense, + '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 + */ + public function futureTransactions(Request $request) + { + $this->init(); + $days = $request->get('days', 30); + + $endDate = now()->addDays($days)->format('Y-m-d'); + $today = now()->format('Y-m-d'); + + $data = DB::select(" + SELECT + t.id, + t.description, + t.amount, + t.type, + t.effective_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 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.effective_date > ? + AND t.effective_date <= ? + AND t.deleted_at IS NULL + ORDER BY t.effective_date ASC + ", [$this->userId, $today, $endDate]); + + $totalIncomeConverted = 0; + $totalExpenseConverted = 0; + + $result = array_map(function($row) use (&$totalIncomeConverted, &$totalExpenseConverted) { + $amount = abs($row->amount); + $converted = $this->convertToPrimaryCurrency($amount, $row->currency); + + if ($row->type === 'credit') { + $totalIncomeConverted += $converted; + } else { + $totalExpenseConverted += $converted; + } + + return [ + 'id' => $row->id, + 'description' => $row->description, + 'amount' => round($amount, 2), + 'amount_converted' => round($converted, 2), + 'currency' => $row->currency, + 'type' => $row->type, + 'date' => $row->effective_date, + 'days_until' => Carbon::parse($row->effective_date)->diffInDays(now()), + 'account' => $row->account_name, + 'category' => $row->category_name, + 'category_icon' => $row->category_icon, + 'cost_center' => $row->cost_center_name, + ]; + }, $data); + + 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) + */ + public function overdueTransactions(Request $request) + { + $this->init(); + + $today = now()->format('Y-m-d'); + + // Cuotas de pasivos vencidas + $overdueInstallments = DB::select(" + SELECT + li.id, + la.name as liability_name, + li.installment_amount as amount, + li.due_date, + la.currency, + DATEDIFF(?, li.due_date) as days_overdue + 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 + ORDER BY li.due_date ASC + ", [$today, $this->userId, $today]); + + $totalOverdueConverted = 0; + $installmentsResult = array_map(function($row) use (&$totalOverdueConverted) { + $converted = $this->convertToPrimaryCurrency($row->amount, $row->currency); + $totalOverdueConverted += $converted; + + return [ + 'id' => $row->id, + 'description' => $row->liability_name, + 'amount' => round($row->amount, 2), + 'amount_converted' => round($converted, 2), + 'currency' => $row->currency, + 'due_date' => $row->due_date, + 'days_overdue' => $row->days_overdue, + 'type' => 'liability_installment', + ]; + }, $overdueInstallments); + + return response()->json([ + 'data' => $installmentsResult, + 'currency' => $this->primaryCurrency, + 'summary' => [ + 'total_overdue' => count($installmentsResult), + '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), + ]); + } } diff --git a/backend/deploy.sh b/backend/deploy.sh old mode 100644 new mode 100755 diff --git a/backend/routes/api.php b/backend/routes/api.php index 0e754c4..4719935 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -257,7 +257,9 @@ // ============================================ Route::prefix('reports')->group(function () { Route::get('summary', [ReportController::class, 'summary']); + Route::get('executive-summary', [ReportController::class, 'executiveSummary']); Route::get('by-category', [ReportController::class, 'byCategory']); + Route::get('by-cost-center', [ReportController::class, 'byCostCenter']); Route::get('monthly-evolution', [ReportController::class, 'monthlyEvolution']); Route::get('by-day-of-week', [ReportController::class, 'byDayOfWeek']); Route::get('top-expenses', [ReportController::class, 'topExpenses']); @@ -265,6 +267,9 @@ Route::get('accounts', [ReportController::class, 'accountsReport']); Route::get('projection', [ReportController::class, 'projection']); Route::get('recurring', [ReportController::class, 'recurringReport']); + Route::get('liabilities', [ReportController::class, 'liabilities']); + Route::get('future-transactions', [ReportController::class, 'futureTransactions']); + Route::get('overdue', [ReportController::class, 'overdueTransactions']); }); // ============================================ diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a271088..470fa82 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -60,7 +60,11 @@ "deselectAll": "Deselect All", "applyToSelected": "Apply to Selected", "batchNoSelection": "Select at least one transaction", - "noResults": "No results" + "noResults": "No results", + "incomes": "Income", + "expenses": "Expenses", + "balance": "Balance", + "current": "Current" }, "auth": { "login": "Login", @@ -90,7 +94,11 @@ "settings": "Settings", "business": "Business", "profile": "Profile", - "help": "Help" + "help": "Help", + "planning": "Planning", + "financialHealth": "Financial Health", + "goals": "Goals", + "budgets": "Budgets" }, "dashboard": { "title": "Dashboard", @@ -240,7 +248,6 @@ "recalculateError": "Error recalculating balances", "adjustBalance": "Adjust Balance", "adjustInfo": "Enter the actual current balance of the account. The system will automatically adjust the initial balance so that the calculations are correct.", - "currentBalance": "Current Balance", "targetBalance": "Actual Balance", "targetBalancePlaceholder": "Enter the actual account balance", "targetBalanceHelp": "The initial balance will be recalculated automatically", @@ -453,7 +460,7 @@ "nominalRate": "Nominal Rate", "effectiveRate": "Effective Rate", "financialSummary": "Financial Summary", - "summaryPoint1": "For every \u20ac1 borrowed, you pay \u20ac0.28 in interest (28%)", + "summaryPoint1": "For every €1 borrowed, you pay €0.28 in interest (28%)", "summaryPointDynamic1": "In this contract, total interest cost represents {{ratio}}% of the principal", "summaryPoint2": "The PRICE system favors the bank in early installments", "summaryPoint3": "Early payments significantly reduce interest", @@ -1252,7 +1259,7 @@ }, "services": { "title": "Service Technical Sheets", - "description": "Manage the COSS (Cost of Service Sold) for each service", + "description": "Description", "add": "New Service", "edit": "Edit Service", "name": "Service Name", @@ -1261,7 +1268,6 @@ "category": "Category", "categoryPlaceholder": "E.g.: Cuts", "duration": "Duration", - "description": "Description", "descriptionPlaceholder": "Describe the service...", "businessSetting": "Business Setting", "selectSetting": "Select setting", @@ -1477,5 +1483,426 @@ "status": "Status", "totalCmv": "Total COGS" } + }, + "financialHealth": { + "title": "Financial Health", + "subtitle": "Complete analysis of your finances", + "lastUpdate": "Last update", + "overallScore": "Your overall score", + "outOf100": "out of 100", + "scoreDescription": "Your financial health score based on various indicators", + "errorLoading": "Error loading financial health data", + "score": "Score", + "savingsRate": "Savings Rate", + "income": "Income", + "expenses": "Expenses", + "vsLastMonth": "vs last month", + "vsAverage": "vs average", + "daysRemaining": "days remaining", + "target": "Target", + "monthlyTarget": "Suggested monthly savings", + "tabs": { + "overview": "Overview", + "metrics": "Metrics", + "categories": "Categories", + "trends": "Trends", + "insights": "Insights" + }, + "levels": { + "excellent": "Excellent Financial Health", + "good": "Good Financial Health", + "moderate": "Moderate Health", + "needs_work": "Needs Improvement", + "critical": "Urgent Attention" + }, + "summary": { + "netWorth": "Net Worth", + "assets": "Assets", + "liabilities": "Liabilities", + "monthlySavings": "Monthly Savings", + "savingsRate": "Savings rate", + "monthlyIncome": "Monthly Income", + "monthlyExpenses": "Monthly Expenses", + "projectedSavings": "Projected Savings", + "byCurrency": "By currency", + "title": "Financial Summary", + "totalAssets": "Total Assets", + "totalLiabilities": "Total Liabilities" + }, + "metrics": { + "savings_capacity": "Savings Capacity", + "debt_control": "Debt Control", + "budget_management": "Budget Management", + "expense_efficiency": "Expense Efficiency", + "emergency_fund": "Emergency Fund", + "financial_stability": "Financial Stability", + "savingsCapacity": "Savings Capacity", + "debtControl": "Debt Control", + "budgetManagement": "Budget Management", + "expenseEfficiency": "Expense Efficiency", + "emergencyFund": "Emergency Fund", + "financialStability": "Financial Stability" + }, + "status": { + "excellent": "Excellent", + "good": "Good", + "adequate": "Adequate", + "moderate": "Moderate", + "needs_improvement": "Needs improvement", + "needs_attention": "Needs attention", + "needs_work": "Must improve", + "negative": "Negative", + "critical": "Critical", + "insufficient": "Insufficient", + "debt_free": "Debt free", + "healthy": "Healthy", + "manageable": "Manageable", + "concerning": "Concerning", + "on_track": "On track", + "exceeded": "Exceeded", + "not_configured": "Not configured", + "very_stable": "Very stable", + "stable": "Stable", + "volatile": "Volatile", + "optimized": "Optimized", + "acceptable": "Acceptable", + "high_discretionary": "High discretionary spending", + "needsImprovement": "Needs Improvement" + }, + "details": { + "savingsRate": "Savings rate", + "monthlySavings": "Monthly savings", + "totalDebt": "Total debt", + "debtToIncome": "Debt/Income", + "activeDebts": "Active debts", + "budgetsConfigured": "Budgets configured", + "compliance": "Compliance", + "exceeded": "Exceeded", + "noBudgets": "No budgets configured", + "liquidAssets": "Liquid assets", + "monthsCovered": "Months covered", + "gap": "Gap", + "incomeVolatility": "Income volatility", + "expenseVolatility": "Expense volatility", + "savingsTrend": "Savings trend" + }, + "distribution": { + "fixed": "Fixed", + "variable": "Variable", + "discretionary": "Discretionary" + }, + "categories": { + "distribution": "Distribution", + "topExpenses": "Top Expenses", + "trends": "Trends", + "title": "Category Analysis" + }, + "trends": { + "monthlyEvolution": "Monthly Evolution", + "incomeTrend": "Income Trend", + "expenseTrend": "Expense Trend", + "savingsTrend": "Savings Trend", + "monthlyComparison": "Monthly Comparison", + "scoreHistory": "Score History", + "title": "Trends" + }, + "trend": { + "increasing": "Increasing", + "decreasing": "Decreasing", + "stable": "Stable" + }, + "insightsTitle": "Situation Analysis", + "noInsights": "No insights available at this time", + "recommendationsTitle": "Recommendations", + "noRecommendations": "Excellent! No urgent recommendations", + "priority": { + "high": "High", + "medium": "Medium" + }, + "projection": { + "title": "Projection", + "currentExpenses": "Current Expenses", + "projected": "Projected", + "nextMonth": "Next Month", + "projectedSavings": "Projected Savings" + }, + "insights": { + "excellentSavings": "Excellent Savings", + "excellentSavingsMsg": "Your savings rate of {{rate}}% is well above average. Keep it up!", + "goodSavings": "Good Savings", + "goodSavingsMsg": "Your savings rate of {{rate}}% is in the healthy range. Consider increasing it gradually.", + "negativeSavings": "Expenses Exceed Income", + "negativeSavingsMsg": "You're spending more than you earn. Review your expenses to avoid debt.", + "spendingMoreThanEarning": "You're spending {{deficit}}€ more than you earn monthly. Review your expenses.", + "debtFree": "Debt Free", + "debtFreeMsg": "You have no active debts. Excellent financial management!", + "highDebt": "High Debt", + "highDebtMsg": "Your debt-to-income ratio is {{ratio}}%. Consider prioritizing debt repayment.", + "budgetsExceeded": "Budgets Exceeded", + "budgetsExceededMsg": "You have {{count}} exceeded budgets this month. Review your expenses.", + "allBudgetsOk": "Budgets Under Control", + "allBudgetsOkMsg": "All your budgets are within limits. Excellent control!", + "goodEmergencyFund": "Solid Emergency Fund", + "goodEmergencyFundMsg": "You have {{months}} months of expenses covered. Your financial security is guaranteed.", + "lowEmergencyFund": "Low Emergency Fund", + "lowEmergencyFundMsg": "You only have {{months}} months of expenses covered. It's recommended to have at least 6 months.", + "emergencyFundMessage": "You're missing {{gap}}€ to cover 6 months of expenses. Consider saving more.", + "stableFinances": "Stable Finances", + "stableFinancesMsg": "Your income and expenses show low volatility, indicating good stability.", + "volatileFinances": "Variable Finances", + "volatileFinancesMsg": "Your finances show high volatility. Consider creating a safety buffer.", + "highConcentration": "Expense Concentration", + "highConcentrationMsg": "{{category}} represents {{percentage}}% of your expenses. Consider diversifying.", + "spendingIncrease": "Spending Increase", + "spendingIncreaseMsg": "{{category}} increased {{change}}% vs last month. Check if necessary.", + "spendingDecrease": "Spending Decrease", + "spendingDecreaseMsg": "{{category}} decreased {{change}}% vs last month. Good job optimizing!", + "spending_spike": "Spending Spike", + "spendingSpike": "{{category}} increased {{increase}}% vs last month. This spike may affect your budget.", + "noBudgets": "No Budgets", + "createBudgetsMessage": "You have no budgets configured. Create budgets to better control your spending.", + "title": "Insights", + "recommendations": "Recommendations" + }, + "recommendations": { + "increaseSavings": "Try to increase your savings rate. Small increments make a big difference over time.", + "reduceSavingsDeficit": "Reduce expenses by {{amount}} monthly to balance your budget and avoid debt.", + "prioritizeDebt": "Prioritize debt repayment. Consider the avalanche method (highest interest first) or snowball (smallest amount first).", + "setupBudgets": "Set up monthly budgets for your main expense categories.", + "reviewBudgets": "Review exceeded budgets and adjust amounts or reduce expenses.", + "buildEmergencyFund": "Build an emergency fund. Goal: 6 months of expenses. Save {{monthly_suggestion}}€/month.", + "increaseEmergencyFund": "Increase your emergency fund. You're missing {{gap}} to cover 6 months of expenses.", + "reduceVolatility": "Work on stabilizing your finances by creating a buffer for variable months.", + "reduceDiscretionary": "Reduce discretionary spending from {{current_percentage}}% to {{target_percentage}}% to improve your savings.", + "createBudgets": "Create budgets for your main spending categories for better control." + }, + "loading": "Analyzing your finances..." + }, + "budgets": { + "title": "Budgets", + "subtitle": "Control your monthly spending", + "addBudget": "Add Budget", + "newBudget": "New Budget", + "editBudget": "Edit Budget", + "deleteBudget": "Delete Budget", + "deleteConfirm": "Are you sure you want to delete this budget?", + "noBudgets": "No budgets", + "noBudgetsDescription": "Start by creating your first monthly budget", + "createFirst": "Create First Budget", + "category": "Category", + "selectCategory": "Select a category", + "amount": "Amount", + "month": "Month", + "budgeted": "Budgeted", + "spent": "Spent", + "remaining": "Remaining", + "exceeded": "Exceeded", + "almostExceeded": "Almost exceeded", + "usage": "Usage", + "copyToNext": "Copy to next month", + "totalBudgeted": "Total Budgeted", + "totalSpent": "Total Spent", + "allCategoriesUsed": "All categories already have budgets this month", + "autoPropagateInfo": "This budget will automatically propagate to future months", + "alert": { + "exceeded": "Budget exceeded!", + "warning": "Warning: near limit", + "onTrack": "On budget" + }, + "summary": { + "totalBudget": "Total Budget", + "totalSpent": "Total Spent", + "available": "Available", + "usagePercent": "% Used" + }, + "yearSummary": "Year Summary", + "currentMonth": "Current", + "noCategory": "No category", + "exceededBy": "Exceeded by", + "copySuccess": "Budgets copied to next month", + "copyTitle": "Copy to next month" + }, + "goals": { + "title": "Financial Goals", + "subtitle": "Track your savings goals", + "newGoal": "New Goal", + "editGoal": "Edit Goal", + "deleteGoal": "Delete Goal", + "deleteConfirm": "Are you sure you want to delete this goal?", + "noGoals": "No goals", + "noGoalsDescription": "Start by creating your first financial goal", + "createFirstGoal": "Create First Goal", + "totalGoals": "Total Goals", + "activeGoals": "Active Goals", + "totalSaved": "Total Saved", + "remaining": "Remaining", + "targetDate": "Target Date", + "targetAmount": "Target Amount", + "currentAmount": "Current Amount", + "monthlyContribution": "Monthly Contribution", + "monthsRemaining": "Months Remaining", + "months": "months", + "progress": "Progress", + "contribute": "Contribute", + "contributeAmount": "Contribution Amount", + "contributeNote": "Note (optional)", + "onTrack": "On track!", + "needsMore": "Need to save {{amount}}/month more", + "statusActive": "Active", + "statusCompleted": "Completed", + "statusPaused": "Paused", + "statusCancelled": "Cancelled", + "addContribution": "Add Contribution", + "addGoal": "Add Goal", + "archive": "Archive", + "color": "Color", + "completed": "Completed", + "congratulations": "Congratulations!", + "contributionDate": "Contribution Date", + "createFirst": "Create First Goal", + "description": "Description", + "goalCompleted": "Goal Completed!", + "icon": "Icon", + "markCompleted": "Mark as Completed", + "name": "Name", + "notes": "Notes", + "notesPlaceholder": "Add a note (optional)", + "pause": "Pause", + "priority": "Priority", + "resume": "Resume", + "viewDetails": "View Details", + "stats": { + "activeGoals": "Active Goals", + "completedGoals": "Completed Goals", + "overallProgress": "Overall Progress", + "totalGoals": "Total Goals", + "totalSaved": "Total Saved", + "totalTarget": "Total Target" + }, + "status": { + "active": "Active", + "advancing": "Advancing", + "cancelled": "Cancelled", + "completed": "Completed", + "paused": "Paused", + "starting": "Starting" + } + }, + "reports": { + "accounts": "Accounts", + "avgExpense": "Average Expense", + "avgIncome": "Average Income", + "balance": "Balance", + "byCategory": "By Category", + "byCostCenter": "By Cost Center", + "comparison": "Comparison", + "custom": "Custom", + "dayOfWeek": { + "friday": "Friday", + "monday": "Monday", + "saturday": "Saturday", + "sunday": "Sunday", + "thursday": "Thursday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "day": "Day" + }, + "daysRemaining": "Days Remaining", + "expenses": "Expenses", + "income": "Income", + "last3Months": "Last 3 Months", + "last6Months": "Last 6 Months", + "lastMonth": "Last Month", + "lastYear": "Last Year", + "monthlyEvolution": "Monthly Evolution", + "period": "Period", + "projectedExpense": "Projected Expense", + "projectedIncome": "Projected Income", + "projection": "Projection", + "projectionTitle": "Month Projection", + "recurring": "Recurring", + "liabilities": "Liabilities", + "futureTransactions": "Future", + "overdue": "Overdue", + "savingsRate": "Savings Rate", + "selectPeriod": "Select Period", + "subtitle": "Detailed analysis of your finances", + "summary": "Summary", + "thisMonth": "This Month", + "thisYear": "This Year", + "title": "Reports", + "topExpenses": "Top Expenses", + "vsAverage": "vs Average", + "vsLastPeriod": "vs Last Period", + "yearComparison": "Year Comparison", + "expenseDistribution": "Expense Distribution", + "categoryDetail": "Category Detail", + "category": "Category", + "amount": "Amount", + "description": "Description", + "date": "Date", + "top20Expenses": "Top 20 Monthly Expenses", + "expensesByDayOfWeek": "Expenses by Day of Week", + "totalSpent": "Total spent", + "totalIncome": "Total Income", + "totalExpense": "Total Expense", + "totalRecurring": "Total Recurring", + "monthlyIncome": "Monthly Income", + "monthlyExpense": "Monthly Expense", + "netRecurring": "Net Recurring", + "recurringList": "Recurring List", + "nextDate": "Next Date", + "totalLiabilities": "Total Liabilities", + "totalDebt": "Total Debt", + "totalPaid": "Total Paid", + "totalPending": "Total Pending", + "overdueInstallments": "overdue installments", + "installments": "installments", + "paid": "Paid", + "pending": "Pending", + "nextInstallment": "Next Installment", + "totalTransactions": "Total Transactions", + "futureIncome": "Future Income", + "futureExpense": "Future Expense", + "netImpact": "Net Impact", + "next30Days": "Next 30 Days", + "account": "Account", + "totalOverdue": "Total Overdue", + "overdueAmount": "Overdue Amount", + "noOverdue": "No Overdue!", + "noOverdueDescription": "You have no overdue payments. Great management!", + "overdueList": "Overdue List", + "dueDate": "Due Date", + "daysOverdue": "Days Overdue", + "historicalAverage": "Historical Average", + "monthProjection": "Month Projection", + "last3Months": "last 3 months", + "currentMonth": "Current Month" + }, + "months": { + "january": "January", + "february": "February", + "march": "March", + "april": "April", + "may": "May", + "june": "June", + "july": "July", + "august": "August", + "september": "September", + "october": "October", + "november": "November", + "december": "December", + "jan": "Jan", + "feb": "Feb", + "mar": "Mar", + "apr": "Apr", + "mayShort": "May", + "jun": "Jun", + "jul": "Jul", + "aug": "Aug", + "sep": "Sep", + "oct": "Oct", + "nov": "Nov", + "dec": "Dec" } -} +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index c827f18..e41bedd 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -60,7 +60,11 @@ "deselectAll": "Desmarcar Todas", "applyToSelected": "Aplicar a Seleccionadas", "batchNoSelection": "Seleccione al menos una transacción", - "noResults": "Sin resultados" + "noResults": "Sin resultados", + "incomes": "Ingresos", + "expenses": "Gastos", + "balance": "Balance", + "current": "Actual" }, "auth": { "login": "Iniciar Sesión", @@ -244,7 +248,6 @@ "recalculateError": "Error al recalcular saldos", "adjustBalance": "Ajustar Saldo", "adjustInfo": "Introduzca el saldo real actual de la cuenta. El sistema ajustará automáticamente el saldo inicial para que los cálculos sean correctos.", - "currentBalance": "Saldo Actual", "targetBalance": "Saldo Real", "targetBalancePlaceholder": "Introduzca el saldo real de la cuenta", "targetBalanceHelp": "El saldo inicial se recalculará automáticamente", @@ -457,7 +460,7 @@ "nominalRate": "Tasa Nominal", "effectiveRate": "Tasa Efectiva", "financialSummary": "Resumen Financiero", - "summaryPoint1": "Por cada \u20ac1 prestado, pagas \u20ac0,28 en intereses (28%)", + "summaryPoint1": "Por cada €1 prestado, pagas €0,28 en intereses (28%)", "summaryPointDynamic1": "En este contrato, el costo total de intereses representa {{ratio}}% del capital", "summaryPoint2": "El sistema PRICE favorece al banco en las primeras cuotas", "summaryPoint3": "Los pagos anticipados reducen significativamente los intereses", @@ -465,7 +468,7 @@ "thisContract": "Este contrato", "interestOverPrincipal": "sobre el capital", "contractCost": "Costo Total del Contrato", - "contractCostText": "Cu\u00e1nto pagar\u00e1s adem\u00e1s del capital prestado:" + "contractCostText": "Cuánto pagarás además del capital prestado:" }, "transactions": { "title": "Transacciones", @@ -1256,7 +1259,7 @@ }, "services": { "title": "Fichas Técnicas de Servicios", - "description": "Administra el CSV (Costo del Servicio Vendido) de cada servicio", + "description": "Descripción", "add": "Nuevo Servicio", "edit": "Editar Servicio", "name": "Nombre del Servicio", @@ -1265,7 +1268,6 @@ "category": "Categoría", "categoryPlaceholder": "Ej: Cortes", "duration": "Duración", - "description": "Descripción", "descriptionPlaceholder": "Describe el servicio...", "businessSetting": "Configuración de Negocio", "selectSetting": "Seleccionar configuración", @@ -1487,27 +1489,176 @@ "subtitle": "Evaluación integral de tus finanzas", "lastUpdate": "Última actualización", "overallScore": "Tu puntuación general", - "excellent": "Excelente Salud", - "good": "Buena Salud", - "moderate": "Salud Moderada", - "needsWork": "Necesita Mejorar", - "critical": "Atención Necesaria", - "onTrack": "Estás en buen camino, pero hay espacio para mejorar", + "outOf100": "de 100", + "scoreDescription": "Evaluación basada en 6 métricas clave", + "errorLoading": "Error al cargar la información de salud financiera", + "score": "Puntuación", + "savingsRate": "Tasa de ahorro", + "income": "Ingresos", + "expenses": "Gastos", + "vsLastMonth": "vs mes anterior", + "vsAverage": "vs promedio", + "daysRemaining": "días restantes", + "target": "Objetivo", + "monthlyTarget": "Ahorro mensual sugerido", + "tabs": { + "overview": "Resumen", + "metrics": "Métricas", + "categories": "Categorías", + "trends": "Tendencias", + "insights": "Insights" + }, + "levels": { + "excellent": "Excelente Salud Financiera", + "good": "Buena Salud Financiera", + "moderate": "Salud Moderada", + "needs_work": "Necesita Mejorar", + "critical": "Atención Urgente" + }, + "summary": { + "netWorth": "Patrimonio Neto", + "assets": "Activos", + "liabilities": "Pasivos", + "monthlySavings": "Ahorro Mensual", + "savingsRate": "Tasa de ahorro", + "monthlyIncome": "Ingresos del mes", + "monthlyExpenses": "Gastos del mes", + "projectedSavings": "Ahorro Proyectado", + "byCurrency": "Por moneda" + }, "metrics": { - "savingsCapacity": "Capacidad de ahorro", - "debtControl": "Control de deudas", - "budgetManagement": "Gestión de presupuesto", - "investments": "Inversiones", - "emergencyFund": "Fondo de emergencia", - "futurePlanning": "Planificación futuro" + "savings_capacity": "Capacidad de Ahorro", + "debt_control": "Control de Deudas", + "budget_management": "Gestión de Presupuesto", + "expense_efficiency": "Eficiencia de Gastos", + "emergency_fund": "Fondo de Emergencia", + "financial_stability": "Estabilidad Financiera" + }, + "status": { + "excellent": "Excelente", + "good": "Bueno", + "adequate": "Adecuado", + "moderate": "Moderado", + "needs_improvement": "Necesita mejorar", + "needs_attention": "Requiere atención", + "needs_work": "Debe mejorar", + "negative": "Negativo", + "critical": "Crítico", + "insufficient": "Insuficiente", + "debt_free": "Sin deudas", + "healthy": "Saludable", + "manageable": "Manejable", + "concerning": "Preocupante", + "on_track": "En meta", + "exceeded": "Excedido", + "not_configured": "No configurado", + "very_stable": "Muy estable", + "stable": "Estable", + "volatile": "Volátil", + "optimized": "Optimizado", + "acceptable": "Aceptable", + "high_discretionary": "Alto gasto discrecional" + }, + "details": { + "savingsRate": "Tasa de ahorro", + "monthlySavings": "Ahorro mensual", + "totalDebt": "Deuda total", + "debtToIncome": "Deuda/Ingresos", + "activeDebts": "Deudas activas", + "budgetsConfigured": "Presupuestos configurados", + "compliance": "Cumplimiento", + "exceeded": "Excedidos", + "noBudgets": "Sin presupuestos configurados", + "liquidAssets": "Activos líquidos", + "monthsCovered": "Meses cubiertos", + "gap": "Brecha", + "incomeVolatility": "Volatilidad ingresos", + "expenseVolatility": "Volatilidad gastos", + "savingsTrend": "Tendencia de ahorro" + }, + "distribution": { + "fixed": "Gastos Fijos", + "variable": "Gastos Variables", + "discretionary": "Gastos Discrecionales" + }, + "categories": { + "distribution": "Distribución de Gastos", + "topExpenses": "Mayores Gastos", + "trends": "Tendencias por Categoría" + }, + "trends": { + "monthlyEvolution": "Evolución Mensual", + "incomeTrend": "Tendencia de Ingresos", + "expenseTrend": "Tendencia de Gastos", + "savingsTrend": "Tendencia de Ahorro", + "monthlyComparison": "Comparación Mensual", + "scoreHistory": "Historial de Puntuación" + }, + "trend": { + "increasing": "En aumento", + "decreasing": "En descenso", + "stable": "Estable" + }, + "insightsTitle": "Análisis de tu Situación", + "noInsights": "No hay insights disponibles en este momento", + "recommendationsTitle": "Recomendaciones", + "noRecommendations": "¡Excelente! No hay recomendaciones urgentes", + "priority": { + "high": "Alta", + "medium": "Media" + }, + "projection": { + "title": "Proyección del Mes", + "currentExpenses": "Gastos Actuales", + "projected": "Proyectado" }, "insights": { - "highPriority": "Prioridad Alta", - "mediumPriority": "Prioridad Media", - "achievement": "Logro Destacado", - "opportunity": "Oportunidad", - "upcomingGoal": "Meta Próxima", - "suggestion": "Sugerencia" + "excellentSavings": "Ahorro Excelente", + "excellentSavingsMsg": "Tu tasa de ahorro del {{rate}}% está muy por encima del promedio. ¡Sigue así!", + "goodSavings": "Buen Ahorro", + "goodSavingsMsg": "Tu tasa de ahorro del {{rate}}% está en el rango saludable. Considera aumentarla gradualmente.", + "negativeSavings": "Gastos Superan Ingresos", + "negativeSavingsMsg": "Estás gastando más de lo que ganas. Revisa tus gastos para evitar endeudamiento.", + "spendingMoreThanEarning": "Estás gastando {{deficit}}€ más de lo que ganas mensualmente. Revisa tus gastos.", + "debtFree": "Sin Deudas", + "debtFreeMsg": "No tienes deudas activas. ¡Excelente gestión financiera!", + "highDebt": "Deuda Elevada", + "highDebtMsg": "Tu ratio deuda/ingresos es del {{ratio}}%. Considera priorizar el pago de deudas.", + "budgetsExceeded": "Presupuestos Excedidos", + "budgetsExceededMsg": "Tienes {{count}} presupuestos excedidos este mes. Revisa tus gastos.", + "allBudgetsOk": "Presupuestos Bajo Control", + "allBudgetsOkMsg": "Todos tus presupuestos están dentro del límite. ¡Excelente control!", + "goodEmergencyFund": "Fondo de Emergencia Sólido", + "goodEmergencyFundMsg": "Tienes {{months}} meses de gastos cubiertos. Tu seguridad financiera está garantizada.", + "lowEmergencyFund": "Fondo de Emergencia Bajo", + "lowEmergencyFundMsg": "Solo tienes {{months}} meses de gastos cubiertos. Se recomienda tener al menos 6 meses.", + "emergencyFundMessage": "Te faltan {{gap}}€ para cubrir 6 meses de gastos. Considera ahorrar más.", + "stableFinances": "Finanzas Estables", + "stableFinancesMsg": "Tus ingresos y gastos muestran baja volatilidad, indicando buena estabilidad.", + "volatileFinances": "Finanzas Variables", + "volatileFinancesMsg": "Tus finanzas muestran alta volatilidad. Considera crear un buffer de seguridad.", + "highConcentration": "Concentración de Gastos", + "highConcentrationMsg": "{{category}} representa el {{percentage}}% de tus gastos. Considera diversificar.", + "spendingIncrease": "Aumento de Gastos", + "spendingIncreaseMsg": "{{category}} aumentó {{change}}% vs mes anterior. Revisa si es necesario.", + "spendingDecrease": "Reducción de Gastos", + "spendingDecreaseMsg": "{{category}} disminuyó {{change}}% vs mes anterior. ¡Buen trabajo optimizando!", + "spending_spike": "Pico de Gasto", + "spendingSpike": "{{category}} aumentó {{increase}}% vs mes anterior. Este pico puede afectar tu presupuesto.", + "noBudgets": "Sin Presupuestos", + "createBudgetsMessage": "No tienes presupuestos configurados. Crea presupuestos para controlar mejor tus gastos." + }, + "recommendations": { + "increaseSavings": "Intenta aumentar tu tasa de ahorro. Pequeños incrementos hacen gran diferencia a largo plazo.", + "reduceSavingsDeficit": "Reduce gastos en {{amount}} mensuales para equilibrar tu presupuesto y evitar deudas.", + "prioritizeDebt": "Prioriza el pago de deudas. Considera el método avalancha (mayor interés primero) o bola de nieve (menor monto primero).", + "setupBudgets": "Configura presupuestos mensuales para tus principales categorías de gasto.", + "reviewBudgets": "Revisa los presupuestos excedidos y ajusta los montos o reduce gastos.", + "buildEmergencyFund": "Construye un fondo de emergencia. Objetivo: 6 meses de gastos. Ahorra {{monthly_suggestion}}€/mes.", + "increaseEmergencyFund": "Aumenta tu fondo de emergencia. Te faltan {{gap}} para cubrir 6 meses de gastos.", + "reduceVolatility": "Trabaja en estabilizar tus finanzas creando un buffer para meses variables.", + "reduceDiscretionary": "Reduce gastos discrecionales del {{current_percentage}}% al {{target_percentage}}% para mejorar tu ahorro.", + "createBudgets": "Crea presupuestos para tus categorías principales de gasto para mejor control." } }, "goals": { @@ -1600,6 +1751,8 @@ "totalBudgeted": "Total Presupuestado", "totalSpent": "Total Gastado", "almostExceeded": "Cerca del límite (80%+)", + "allCategoriesUsed": "Ya tienes presupuesto para todas las categorías este mes", + "autoPropagateInfo": "Este presupuesto se propagará automáticamente a los meses siguientes", "summary": { "totalBudget": "Presupuesto Total", "totalSpent": "Gastado", @@ -1610,18 +1763,27 @@ "onTrack": "Bajo control", "warning": "Cerca del límite", "exceeded": "¡Excedido!" - } + }, + "currentMonth": "Actual", + "noCategory": "Sin categoría", + "exceededBy": "Excedido en", + "copySuccess": "Presupuestos copiados al siguiente mes", + "copyTitle": "Copiar al próximo mes" }, "reports": { "title": "Reportes", "subtitle": "Análisis detallado de tus finanzas", "summary": "Resumen", "byCategory": "Por Categoría", + "byCostCenter": "Por Centro de Costo", "monthlyEvolution": "Evolución Mensual", "comparison": "Comparativa", "topExpenses": "Mayores Gastos", "projection": "Proyección", "recurring": "Recurrentes", + "liabilities": "Pasivos", + "futureTransactions": "Futuras", + "overdue": "Vencidas", "accounts": "Por Cuenta", "period": "Período", "selectPeriod": "Seleccionar período", @@ -1646,13 +1808,83 @@ "wednesday": "Miércoles", "thursday": "Jueves", "friday": "Viernes", - "saturday": "Sábado" + "saturday": "Sábado", + "day": "Día" }, "projectionTitle": "Proyección del mes", "projectedExpense": "Gasto proyectado", "projectedIncome": "Ingreso proyectado", "daysRemaining": "Días restantes", - "vsAverage": "vs promedio histórico" + "vsAverage": "vs promedio histórico", + "yearComparison": "Comparativa Anual", + "expenseDistribution": "Distribución de Gastos", + "categoryDetail": "Detalle por Categoría", + "category": "Categoría", + "amount": "Monto", + "description": "Descripción", + "date": "Fecha", + "top20Expenses": "Top 20 Gastos del Mes", + "expensesByDayOfWeek": "Gastos por Día de la Semana", + "totalSpent": "Total gastado", + "totalIncome": "Total Ingresos", + "totalExpense": "Total Gastos", + "totalRecurring": "Total Recurrentes", + "monthlyIncome": "Ingreso Mensual", + "monthlyExpense": "Gasto Mensual", + "netRecurring": "Neto Recurrente", + "recurringList": "Lista de Recurrentes", + "nextDate": "Próxima Fecha", + "totalLiabilities": "Total Pasivos", + "totalDebt": "Deuda Total", + "totalPaid": "Total Pagado", + "totalPending": "Total Pendiente", + "overdueInstallments": "cuotas vencidas", + "installments": "cuotas", + "paid": "Pagado", + "pending": "Pendiente", + "nextInstallment": "Próxima Cuota", + "totalTransactions": "Total Transacciones", + "futureIncome": "Ingresos Futuros", + "futureExpense": "Gastos Futuros", + "netImpact": "Impacto Neto", + "next30Days": "Próximos 30 Días", + "account": "Cuenta", + "totalOverdue": "Total Vencidos", + "overdueAmount": "Monto Vencido", + "noOverdue": "¡Sin Vencidos!", + "noOverdueDescription": "No tienes pagos vencidos. ¡Excelente gestión!", + "overdueList": "Lista de Vencidos", + "dueDate": "Fecha Vencimiento", + "daysOverdue": "Días de Atraso", + "historicalAverage": "Promedio histórico", + "monthProjection": "Proyección del mes", + "last3Months": "últimos 3 meses", + "currentMonth": "Mes Actual" + }, + "months": { + "january": "Enero", + "february": "Febrero", + "march": "Marzo", + "april": "Abril", + "may": "Mayo", + "june": "Junio", + "july": "Julio", + "august": "Agosto", + "september": "Septiembre", + "october": "Octubre", + "november": "Noviembre", + "december": "Diciembre", + "jan": "Ene", + "feb": "Feb", + "mar": "Mar", + "apr": "Abr", + "mayShort": "May", + "jun": "Jun", + "jul": "Jul", + "aug": "Ago", + "sep": "Sep", + "oct": "Oct", + "nov": "Nov", + "dec": "Dic" } -} - +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 04bec24..a6ba9a0 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -61,7 +61,11 @@ "deselectAll": "Desmarcar Todas", "applyToSelected": "Aplicar nas Selecionadas", "batchNoSelection": "Selecione pelo menos uma transação", - "noResults": "Sem resultados" + "noResults": "Sem resultados", + "incomes": "Receitas", + "expenses": "Despesas", + "balance": "Saldo", + "current": "Atual" }, "auth": { "login": "Entrar", @@ -91,7 +95,11 @@ "settings": "Configurações", "business": "Negócios", "profile": "Perfil", - "help": "Ajuda" + "help": "Ajuda", + "planning": "Planejamento", + "financialHealth": "Saúde Financeira", + "goals": "Metas", + "budgets": "Orçamentos" }, "dashboard": { "title": "Painel de Controle", @@ -242,7 +250,6 @@ "recalculateError": "Erro ao recalcular saldos", "adjustBalance": "Ajustar Saldo", "adjustInfo": "Informe o saldo real atual da conta. O sistema ajustará automaticamente o saldo inicial para que os cálculos fiquem corretos.", - "currentBalance": "Saldo Atual", "targetBalance": "Saldo Real", "targetBalancePlaceholder": "Digite o saldo real da conta", "targetBalanceHelp": "O saldo inicial será recalculado automaticamente", @@ -455,15 +462,15 @@ "nominalRate": "Taxa Nominal", "effectiveRate": "Taxa Efetiva", "financialSummary": "Resumo Financeiro", - "summaryPoint1": "Para cada \u20ac1 emprestado, voc\u00ea paga \u20ac0,28 em juros (28%)", + "summaryPoint1": "Para cada €1 emprestado, você paga €0,28 em juros (28%)", "summaryPointDynamic1": "Neste contrato, o custo total de juros representa {{ratio}}% do capital", "summaryPoint2": "O sistema PRICE favorece o banco nas primeiras parcelas", "summaryPoint3": "Pagamentos antecipados reduzem significativamente os juros", - "summaryPoint4": "Encargos extras de quaisquer sobrepagamentos s\u00e3o registrados como taxas", + "summaryPoint4": "Encargos extras de quaisquer sobrepagamentos são registrados como taxas", "thisContract": "Este contrato", "interestOverPrincipal": "sobre o capital", "contractCost": "Custo Total do Contrato", - "contractCostText": "Quanto voc\u00ea pagar\u00e1 al\u00e9m do capital emprestado:" + "contractCostText": "Quanto você pagará além do capital emprestado:" }, "transactions": { "title": "Transações", @@ -1254,7 +1261,7 @@ }, "services": { "title": "Fichas Técnicas de Serviços", - "description": "Gerencie o CSV (Custo do Serviço Vendido) de cada serviço", + "description": "Descrição", "add": "Novo Serviço", "edit": "Editar Serviço", "name": "Nome do Serviço", @@ -1263,7 +1270,6 @@ "category": "Categoria", "categoryPlaceholder": "Ex: Cortes", "duration": "Duração", - "description": "Descrição", "descriptionPlaceholder": "Descreva o serviço...", "businessSetting": "Configuração de Negócio", "selectSetting": "Selecionar configuração", @@ -1479,5 +1485,426 @@ "status": "Status", "totalCmv": "CMV Total" } + }, + "financialHealth": { + "title": "Saúde Financeira", + "subtitle": "Análise completa das suas finanças", + "lastUpdate": "Última atualização", + "overallScore": "Sua pontuação geral", + "outOf100": "de 100", + "scoreDescription": "Sua pontuação de saúde financeira baseada em vários indicadores", + "errorLoading": "Erro ao carregar dados de saúde financeira", + "score": "Pontuação", + "savingsRate": "Taxa de Poupança", + "income": "Receitas", + "expenses": "Despesas", + "vsLastMonth": "vs mês anterior", + "vsAverage": "vs média", + "daysRemaining": "dias restantes", + "target": "Objetivo", + "monthlyTarget": "Economia mensal sugerida", + "tabs": { + "overview": "Visão Geral", + "metrics": "Métricas", + "categories": "Categorias", + "trends": "Tendências", + "insights": "Insights" + }, + "levels": { + "excellent": "Excelente Saúde Financeira", + "good": "Boa Saúde Financeira", + "moderate": "Saúde Moderada", + "needs_work": "Precisa Melhorar", + "critical": "Atenção Urgente" + }, + "summary": { + "netWorth": "Patrimônio Líquido", + "assets": "Ativos", + "liabilities": "Passivos", + "monthlySavings": "Poupança Mensal", + "savingsRate": "Taxa de poupança", + "monthlyIncome": "Renda Mensal", + "monthlyExpenses": "Despesas Mensais", + "projectedSavings": "Poupança Projetada", + "byCurrency": "Por moeda", + "title": "Resumo Financeiro", + "totalAssets": "Ativos Totais", + "totalLiabilities": "Passivos Totais" + }, + "metrics": { + "savings_capacity": "Capacidade de Poupança", + "debt_control": "Controle de Dívidas", + "budget_management": "Gestão de Orçamento", + "expense_efficiency": "Eficiência de Gastos", + "emergency_fund": "Fundo de Emergência", + "financial_stability": "Estabilidade Financeira", + "savingsCapacity": "Capacidade de Poupança", + "debtControl": "Controle de Dívidas", + "budgetManagement": "Gestão de Orçamento", + "expenseEfficiency": "Eficiência de Gastos", + "emergencyFund": "Fundo de Emergência", + "financialStability": "Estabilidade Financeira" + }, + "status": { + "excellent": "Excelente", + "good": "Bom", + "adequate": "Adequado", + "moderate": "Moderado", + "needs_improvement": "Precisa melhorar", + "needs_attention": "Requer atenção", + "needs_work": "Deve melhorar", + "negative": "Negativo", + "critical": "Crítico", + "insufficient": "Insuficiente", + "debt_free": "Sem dívidas", + "healthy": "Saudável", + "manageable": "Controlável", + "concerning": "Preocupante", + "on_track": "No caminho certo", + "exceeded": "Excedido", + "not_configured": "Não configurado", + "very_stable": "Muito estável", + "stable": "Estável", + "volatile": "Volátil", + "optimized": "Otimizado", + "acceptable": "Aceitável", + "high_discretionary": "Alto gasto discricionário", + "needsImprovement": "Precisa Melhorar" + }, + "details": { + "savingsRate": "Taxa de poupança", + "monthlySavings": "Poupança mensal", + "totalDebt": "Dívida total", + "debtToIncome": "Dívida/Receita", + "activeDebts": "Dívidas ativas", + "budgetsConfigured": "Orçamentos configurados", + "compliance": "Cumprimento", + "exceeded": "Excedidos", + "noBudgets": "Sem orçamentos configurados", + "liquidAssets": "Ativos líquidos", + "monthsCovered": "Meses cobertos", + "gap": "Diferença", + "incomeVolatility": "Volatilidade receitas", + "expenseVolatility": "Volatilidade despesas", + "savingsTrend": "Tendência de poupança" + }, + "distribution": { + "fixed": "Fixos", + "variable": "Variáveis", + "discretionary": "Discricionários" + }, + "categories": { + "distribution": "Distribuição", + "topExpenses": "Maiores Despesas", + "trends": "Tendências", + "title": "Análise de Categorias" + }, + "trends": { + "monthlyEvolution": "Evolução Mensal", + "incomeTrend": "Tendência de Receitas", + "expenseTrend": "Tendência de Despesas", + "savingsTrend": "Tendência de Poupança", + "monthlyComparison": "Comparação Mensal", + "scoreHistory": "Histórico de Pontuação", + "title": "Tendências" + }, + "trend": { + "increasing": "Aumentando", + "decreasing": "Diminuindo", + "stable": "Estável" + }, + "insightsTitle": "Análise da Sua Situação", + "noInsights": "Não há insights disponíveis no momento", + "recommendationsTitle": "Recomendações", + "noRecommendations": "Excelente! Não há recomendações urgentes", + "priority": { + "high": "Alta", + "medium": "Média" + }, + "projection": { + "title": "Projeção", + "currentExpenses": "Despesas Atuais", + "projected": "Projetado", + "nextMonth": "Próximo Mês", + "projectedSavings": "Poupança Projetada" + }, + "insights": { + "excellentSavings": "Poupança Excelente", + "excellentSavingsMsg": "Sua taxa de poupança de {{rate}}% está muito acima da média. Continue assim!", + "goodSavings": "Boa Poupança", + "goodSavingsMsg": "Sua taxa de poupança de {{rate}}% está na faixa saudável. Considere aumentá-la gradualmente.", + "negativeSavings": "Despesas Superam Receitas", + "negativeSavingsMsg": "Você está gastando mais do que ganha. Revise suas despesas para evitar endividamento.", + "spendingMoreThanEarning": "Você está gastando {{deficit}}€ mais do que ganha mensalmente. Revise suas despesas.", + "debtFree": "Sem Dívidas", + "debtFreeMsg": "Você não tem dívidas ativas. Excelente gestão financeira!", + "highDebt": "Dívida Elevada", + "highDebtMsg": "Sua relação dívida/receita é de {{ratio}}%. Considere priorizar o pagamento de dívidas.", + "budgetsExceeded": "Orçamentos Excedidos", + "budgetsExceededMsg": "Você tem {{count}} orçamentos excedidos este mês. Revise suas despesas.", + "allBudgetsOk": "Orçamentos Sob Controle", + "allBudgetsOkMsg": "Todos os seus orçamentos estão dentro do limite. Excelente controle!", + "goodEmergencyFund": "Fundo de Emergência Sólido", + "goodEmergencyFundMsg": "Você tem {{months}} meses de despesas cobertos. Sua segurança financeira está garantida.", + "lowEmergencyFund": "Fundo de Emergência Baixo", + "lowEmergencyFundMsg": "Você só tem {{months}} meses de despesas cobertos. Recomenda-se ter pelo menos 6 meses.", + "emergencyFundMessage": "Faltam {{gap}}€ para cobrir 6 meses de despesas. Considere poupar mais.", + "stableFinances": "Finanças Estáveis", + "stableFinancesMsg": "Suas receitas e despesas mostram baixa volatilidade, indicando boa estabilidade.", + "volatileFinances": "Finanças Variáveis", + "volatileFinancesMsg": "Suas finanças mostram alta volatilidade. Considere criar um buffer de segurança.", + "highConcentration": "Concentração de Gastos", + "highConcentrationMsg": "{{category}} representa {{percentage}}% das suas despesas. Considere diversificar.", + "spendingIncrease": "Aumento de Gastos", + "spendingIncreaseMsg": "{{category}} aumentou {{change}}% vs mês anterior. Verifique se é necessário.", + "spendingDecrease": "Redução de Gastos", + "spendingDecreaseMsg": "{{category}} diminuiu {{change}}% vs mês anterior. Bom trabalho otimizando!", + "spending_spike": "Pico de Gasto", + "spendingSpike": "{{category}} aumentou {{increase}}% vs mês anterior. Este pico pode afetar seu orçamento.", + "noBudgets": "Sem Orçamentos", + "createBudgetsMessage": "Você não tem orçamentos configurados. Crie orçamentos para controlar melhor seus gastos.", + "title": "Insights", + "recommendations": "Recomendações" + }, + "recommendations": { + "increaseSavings": "Tente aumentar sua taxa de poupança. Pequenos incrementos fazem grande diferença a longo prazo.", + "reduceSavingsDeficit": "Reduza despesas em {{amount}} mensais para equilibrar seu orçamento e evitar dívidas.", + "prioritizeDebt": "Priorize o pagamento de dívidas. Considere o método avalanche (maior juro primeiro) ou bola de neve (menor valor primeiro).", + "setupBudgets": "Configure orçamentos mensais para suas principais categorias de gastos.", + "reviewBudgets": "Revise os orçamentos excedidos e ajuste os valores ou reduza gastos.", + "buildEmergencyFund": "Construa um fundo de emergência. Objetivo: 6 meses de despesas. Poupe {{monthly_suggestion}}€/mês.", + "increaseEmergencyFund": "Aumente seu fundo de emergência. Faltam {{gap}} para cobrir 6 meses de despesas.", + "reduceVolatility": "Trabalhe em estabilizar suas finanças criando um buffer para meses variáveis.", + "reduceDiscretionary": "Reduza gastos discricionários de {{current_percentage}}% para {{target_percentage}}% para melhorar sua poupança.", + "createBudgets": "Crie orçamentos para suas principais categorias de gastos para melhor controle." + }, + "loading": "Analisando suas finanças..." + }, + "budgets": { + "title": "Orçamentos", + "subtitle": "Controle seus gastos mensais", + "addBudget": "Adicionar Orçamento", + "newBudget": "Novo Orçamento", + "editBudget": "Editar Orçamento", + "deleteBudget": "Excluir Orçamento", + "deleteConfirm": "Tem certeza que deseja excluir este orçamento?", + "noBudgets": "Nenhum orçamento", + "noBudgetsDescription": "Comece criando seu primeiro orçamento mensal", + "createFirst": "Criar Primeiro Orçamento", + "category": "Categoria", + "selectCategory": "Selecione uma categoria", + "amount": "Valor", + "month": "Mês", + "budgeted": "Orçado", + "spent": "Gasto", + "remaining": "Restante", + "exceeded": "Excedido", + "almostExceeded": "Quase excedido", + "usage": "Uso", + "copyToNext": "Copiar para próximo mês", + "totalBudgeted": "Total Orçado", + "totalSpent": "Total Gasto", + "allCategoriesUsed": "Todas as categorias já possuem orçamento este mês", + "autoPropagateInfo": "Este orçamento será propagado automaticamente para os meses seguintes", + "alert": { + "exceeded": "Orçamento excedido!", + "warning": "Atenção: próximo do limite", + "onTrack": "Dentro do orçamento" + }, + "summary": { + "totalBudget": "Orçamento Total", + "totalSpent": "Total Gasto", + "available": "Disponível", + "usagePercent": "% Utilizado" + }, + "yearSummary": "Resumo do Ano", + "currentMonth": "Atual", + "noCategory": "Sem categoria", + "exceededBy": "Excedido em", + "copySuccess": "Orçamentos copiados para o próximo mês", + "copyTitle": "Copiar para próximo mês" + }, + "goals": { + "title": "Metas Financeiras", + "subtitle": "Acompanhe suas metas de economia", + "newGoal": "Nova Meta", + "editGoal": "Editar Meta", + "deleteGoal": "Excluir Meta", + "deleteConfirm": "Tem certeza que deseja excluir esta meta?", + "noGoals": "Nenhuma meta", + "noGoalsDescription": "Comece criando sua primeira meta financeira", + "createFirstGoal": "Criar Primeira Meta", + "totalGoals": "Total de Metas", + "activeGoals": "Metas Ativas", + "totalSaved": "Total Economizado", + "remaining": "Restante", + "targetDate": "Data Alvo", + "targetAmount": "Valor Alvo", + "currentAmount": "Valor Atual", + "monthlyContribution": "Contribuição Mensal", + "monthsRemaining": "Meses Restantes", + "months": "meses", + "progress": "Progresso", + "contribute": "Contribuir", + "contributeAmount": "Valor da Contribuição", + "contributeNote": "Nota (opcional)", + "onTrack": "No caminho certo!", + "needsMore": "Precisa economizar mais {{amount}}/mês", + "statusActive": "Ativa", + "statusCompleted": "Concluída", + "statusPaused": "Pausada", + "statusCancelled": "Cancelada", + "addContribution": "Adicionar Contribuição", + "addGoal": "Adicionar Meta", + "archive": "Arquivar", + "color": "Cor", + "completed": "Concluídas", + "congratulations": "Parabéns!", + "contributionDate": "Data da Contribuição", + "createFirst": "Criar Primeira Meta", + "description": "Descrição", + "goalCompleted": "Meta Concluída!", + "icon": "Ícone", + "markCompleted": "Marcar como Concluída", + "name": "Nome", + "notes": "Notas", + "notesPlaceholder": "Adicione uma nota (opcional)", + "pause": "Pausar", + "priority": "Prioridade", + "resume": "Retomar", + "viewDetails": "Ver Detalhes", + "stats": { + "activeGoals": "Metas Ativas", + "completedGoals": "Metas Concluídas", + "overallProgress": "Progresso Geral", + "totalGoals": "Total de Metas", + "totalSaved": "Total Economizado", + "totalTarget": "Objetivo Total" + }, + "status": { + "active": "Ativa", + "advancing": "Avançando", + "cancelled": "Cancelada", + "completed": "Concluída", + "paused": "Pausada", + "starting": "Iniciando" + } + }, + "reports": { + "accounts": "Contas", + "avgExpense": "Despesa Média", + "avgIncome": "Receita Média", + "balance": "Saldo", + "byCategory": "Por Categoria", + "byCostCenter": "Por Centro de Custo", + "comparison": "Comparação", + "custom": "Personalizado", + "dayOfWeek": { + "friday": "Sexta", + "monday": "Segunda", + "saturday": "Sábado", + "sunday": "Domingo", + "thursday": "Quinta", + "tuesday": "Terça", + "wednesday": "Quarta", + "day": "Dia" + }, + "daysRemaining": "Dias Restantes", + "expenses": "Despesas", + "income": "Receitas", + "last3Months": "Últimos 3 Meses", + "last6Months": "Últimos 6 Meses", + "lastMonth": "Mês Passado", + "lastYear": "Ano Passado", + "monthlyEvolution": "Evolução Mensal", + "period": "Período", + "projectedExpense": "Despesa Projetada", + "projectedIncome": "Receita Projetada", + "projection": "Projeção", + "projectionTitle": "Projeção do Mês", + "recurring": "Recorrentes", + "liabilities": "Passivos", + "futureTransactions": "Futuras", + "overdue": "Vencidas", + "savingsRate": "Taxa de Poupança", + "selectPeriod": "Selecionar Período", + "subtitle": "Análise detalhada das suas finanças", + "summary": "Resumo", + "thisMonth": "Este Mês", + "thisYear": "Este Ano", + "title": "Relatórios", + "topExpenses": "Maiores Despesas", + "vsAverage": "vs Média", + "vsLastPeriod": "vs Período Anterior", + "yearComparison": "Comparativo Anual", + "expenseDistribution": "Distribuição de Despesas", + "categoryDetail": "Detalhes por Categoria", + "category": "Categoria", + "amount": "Valor", + "description": "Descrição", + "date": "Data", + "top20Expenses": "Top 20 Despesas do Mês", + "expensesByDayOfWeek": "Despesas por Dia da Semana", + "totalSpent": "Total gasto", + "totalIncome": "Total Receitas", + "totalExpense": "Total Despesas", + "totalRecurring": "Total Recorrentes", + "monthlyIncome": "Receita Mensal", + "monthlyExpense": "Despesa Mensal", + "netRecurring": "Saldo Recorrente", + "recurringList": "Lista de Recorrentes", + "nextDate": "Próxima Data", + "totalLiabilities": "Total Passivos", + "totalDebt": "Dívida Total", + "totalPaid": "Total Pago", + "totalPending": "Total Pendente", + "overdueInstallments": "parcelas vencidas", + "installments": "parcelas", + "paid": "Pago", + "pending": "Pendente", + "nextInstallment": "Próxima Parcela", + "totalTransactions": "Total Transações", + "futureIncome": "Receitas Futuras", + "futureExpense": "Despesas Futuras", + "netImpact": "Impacto Líquido", + "next30Days": "Próximos 30 Dias", + "account": "Conta", + "totalOverdue": "Total Vencidos", + "overdueAmount": "Valor Vencido", + "noOverdue": "Sem Vencidos!", + "noOverdueDescription": "Você não tem pagamentos vencidos. Excelente gestão!", + "overdueList": "Lista de Vencidos", + "dueDate": "Data de Vencimento", + "daysOverdue": "Dias de Atraso", + "historicalAverage": "Média histórica", + "monthProjection": "Projeção do mês", + "last3Months": "últimos 3 meses", + "currentMonth": "Mês Atual" + }, + "months": { + "january": "Janeiro", + "february": "Fevereiro", + "march": "Março", + "april": "Abril", + "may": "Maio", + "june": "Junho", + "july": "Julho", + "august": "Agosto", + "september": "Setembro", + "october": "Outubro", + "november": "Novembro", + "december": "Dezembro", + "jan": "Jan", + "feb": "Fev", + "mar": "Mar", + "apr": "Abr", + "mayShort": "Mai", + "jun": "Jun", + "jul": "Jul", + "aug": "Ago", + "sep": "Set", + "oct": "Out", + "nov": "Nov", + "dec": "Dez" } -} +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 204fb57..a35fb6e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -390,9 +390,9 @@ a { } .sidebar-link-text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + white-space: normal; + line-height: 1.3; + word-wrap: break-word; } /* Sidebar Groups */ diff --git a/frontend/src/pages/Budgets.jsx b/frontend/src/pages/Budgets.jsx index da8d0b0..13c8b44 100644 --- a/frontend/src/pages/Budgets.jsx +++ b/frontend/src/pages/Budgets.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { budgetService, categoryService } from '../services/api'; import useFormatters from '../hooks/useFormatters'; +import { getCurrencyByCode } from '../config/currencies'; import ConfirmModal from '../components/ConfirmModal'; const Budgets = () => { @@ -18,26 +19,30 @@ const Budgets = () => { const [editingBudget, setEditingBudget] = useState(null); const [deleteBudget, setDeleteBudget] = useState(null); const [yearSummary, setYearSummary] = useState(null); + const [primaryCurrency, setPrimaryCurrency] = useState('EUR'); const [formData, setFormData] = useState({ category_id: '', amount: '', }); - const months = [ - { value: 1, label: 'Enero' }, - { value: 2, label: 'Febrero' }, - { value: 3, label: 'Marzo' }, - { value: 4, label: 'Abril' }, - { value: 5, label: 'Mayo' }, - { value: 6, label: 'Junio' }, - { value: 7, label: 'Julio' }, - { value: 8, label: 'Agosto' }, - { value: 9, label: 'Septiembre' }, - { value: 10, label: 'Octubre' }, - { value: 11, label: 'Noviembre' }, - { value: 12, label: 'Diciembre' }, + // Meses con i18n + const getMonths = () => [ + { value: 1, label: t('months.january') }, + { value: 2, label: t('months.february') }, + { value: 3, label: t('months.march') }, + { value: 4, label: t('months.april') }, + { value: 5, label: t('months.may') }, + { value: 6, label: t('months.june') }, + { value: 7, label: t('months.july') }, + { value: 8, label: t('months.august') }, + { value: 9, label: t('months.september') }, + { value: 10, label: t('months.october') }, + { value: 11, label: t('months.november') }, + { value: 12, label: t('months.december') }, ]; + const months = getMonths(); + useEffect(() => { loadData(); }, [year, month]); @@ -48,13 +53,28 @@ const Budgets = () => { const [budgetsData, categoriesData, availableData, summaryData] = await Promise.all([ budgetService.getAll({ year, month }), categoryService.getAll(), - budgetService.getAvailableCategories(year, month), - budgetService.getYearSummary(year), + budgetService.getAvailableCategories({ year, month }), + budgetService.getYearSummary({ year }), ]); - setBudgets(budgetsData); - setCategories(categoriesData.filter(c => c.type === 'debit')); - setAvailableCategories(availableData); - setYearSummary(summaryData); + + // Extraer datos del response si viene en formato { data, ... } + const budgetsList = budgetsData?.data || budgetsData; + setBudgets(Array.isArray(budgetsList) ? budgetsList : []); + + // Detectar moneda primaria de los presupuestos o usar EUR + if (budgetsList?.length > 0 && budgetsList[0].currency) { + setPrimaryCurrency(budgetsList[0].currency); + } + + const cats = categoriesData?.data || categoriesData; + // Filtrar categorías de gastos: expense o both + setCategories(Array.isArray(cats) ? cats.filter(c => c.type === 'expense' || c.type === 'both') : []); + + // Categorías disponibles (no usadas aún) + const available = Array.isArray(availableData) ? availableData : []; + setAvailableCategories(available); + + setYearSummary(Array.isArray(summaryData) ? summaryData : []); } catch (error) { console.error('Error loading budgets:', error); } finally { @@ -140,14 +160,47 @@ const Budgets = () => { return '#10b981'; }; - // Calculate totals + // Calculate totals agrupados por moneda + const safeBudgets = Array.isArray(budgets) ? budgets : []; + + // Agrupar por moneda + const totalsByCurrency = safeBudgets.reduce((acc, b) => { + const curr = b.currency || primaryCurrency; + if (!acc[curr]) { + acc[curr] = { budgeted: 0, spent: 0 }; + } + acc[curr].budgeted += parseFloat(b.amount || 0); + acc[curr].spent += parseFloat(b.spent_amount || 0); + return acc; + }, {}); + + // Totales principales (para compatibilidad) const totals = { - budgeted: budgets.reduce((sum, b) => sum + parseFloat(b.amount), 0), - spent: budgets.reduce((sum, b) => sum + parseFloat(b.spent_amount || 0), 0), + budgeted: safeBudgets.reduce((sum, b) => sum + parseFloat(b.amount || 0), 0), + spent: safeBudgets.reduce((sum, b) => sum + parseFloat(b.spent_amount || 0), 0), }; totals.remaining = totals.budgeted - totals.spent; totals.percentage = totals.budgeted > 0 ? (totals.spent / totals.budgeted) * 100 : 0; + // Formatear totales por moneda + const formatTotalsByCurrency = (type) => { + const entries = Object.entries(totalsByCurrency); + if (entries.length === 0) return currency(0, primaryCurrency); + if (entries.length === 1) { + const [curr, vals] = entries[0]; + const value = type === 'budgeted' ? vals.budgeted : + type === 'spent' ? vals.spent : + vals.budgeted - vals.spent; + return currency(value, curr); + } + return entries.map(([curr, vals]) => { + const value = type === 'budgeted' ? vals.budgeted : + type === 'spent' ? vals.spent : + vals.budgeted - vals.spent; + return currency(value, curr); + }).join(' + '); + }; + if (loading) { return (