webmoney/backend/app/Http/Controllers/Api/FinancialHealthController.php

1435 lines
56 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Transaction;
use App\Models\Account;
use App\Models\LiabilityAccount;
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)
{
$this->userId = Auth::id();
$this->setPrimaryCurrency();
$this->loadExchangeRates();
// Obtener datos base
$financialSummary = $this->getFinancialSummary();
$cashFlowAnalysis = $this->analyzeCashFlow();
$categoryAnalysis = $this->analyzeCategories();
$trendAnalysis = $this->analyzeTrends();
// Calcular métricas detalladas
$metrics = [
'savings_capacity' => $this->calculateSavingsCapacity($cashFlowAnalysis),
'debt_control' => $this->calculateDebtControl($cashFlowAnalysis),
'budget_management' => $this->calculateBudgetManagement(),
'expense_efficiency' => $this->calculateExpenseEfficiency($categoryAnalysis),
'emergency_fund' => $this->calculateEmergencyFund($cashFlowAnalysis),
'financial_stability' => $this->calculateFinancialStability($trendAnalysis),
];
// Calcular puntuación general ponderada
$weights = [
'savings_capacity' => 0.25,
'debt_control' => 0.20,
'budget_management' => 0.15,
'expense_efficiency' => 0.15,
'emergency_fund' => 0.15,
'financial_stability' => 0.10,
];
$overallScore = 0;
foreach ($metrics as $key => $metric) {
$overallScore += $metric['score'] * $weights[$key];
}
$overallScore = round($overallScore);
// Determinar nivel de salud
$healthLevel = $this->getHealthLevel($overallScore);
// Generar insights avanzados
$insights = $this->generateAdvancedInsights($metrics, $categoryAnalysis, $trendAnalysis);
// Generar recomendaciones personalizadas
$recommendations = $this->generateRecommendations($metrics, $categoryAnalysis);
return response()->json([
'overall_score' => $overallScore,
'health_level' => $healthLevel,
'last_updated' => now()->toIso8601String(),
'currency' => $this->primaryCurrency,
// 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($cashFlow)
{
$savingsRate = $cashFlow['savings_rate'];
// Puntuación basada en tasa de ahorro
$score = match(true) {
$savingsRate >= 30 => 100,
$savingsRate >= 20 => 80 + (($savingsRate - 20) * 2),
$savingsRate >= 10 => 60 + (($savingsRate - 10) * 2),
$savingsRate >= 0 => 40 + ($savingsRate * 2),
default => max(0, 40 + $savingsRate),
};
return [
'score' => round(min(100, max(0, $score))),
'savings_rate' => $savingsRate,
'monthly_income' => $cashFlow['avg_monthly_income'],
'monthly_expenses' => $cashFlow['avg_monthly_expenses'],
'monthly_savings' => $cashFlow['avg_monthly_savings'],
'status' => $savingsRate >= 20 ? 'excellent' : ($savingsRate >= 10 ? 'good' : ($savingsRate >= 0 ? 'needs_improvement' : 'negative')),
];
}
/**
* Calcular control de deudas con soporte multi-divisa
*/
private function calculateDebtControl($cashFlow)
{
$liabilities = LiabilityAccount::where('user_id', $this->userId)
->where('status', 'active')
->get();
// Convertir todas las deudas a moneda principal
$totalDebt = 0;
$totalCreditLimit = 0;
$monthlyPayments = 0;
$debtByCurrency = [];
foreach ($liabilities as $liability) {
$currency = $liability->currency ?? 'EUR';
$balance = $liability->remaining_balance ?? 0;
$limit = $liability->credit_limit ?? 0;
$payment = $liability->monthly_payment ?? 0;
$totalDebt += $this->convertToPrimaryCurrency($balance, $currency);
$totalCreditLimit += $this->convertToPrimaryCurrency($limit, $currency);
$monthlyPayments += $this->convertToPrimaryCurrency($payment, $currency);
if (!isset($debtByCurrency[$currency])) {
$debtByCurrency[$currency] = 0;
}
$debtByCurrency[$currency] += $balance;
}
$totalCreditLimit = $totalCreditLimit ?: 1;
$monthlyIncome = $cashFlow['avg_monthly_income'];
// Ratios
$debtToIncomeRatio = $monthlyIncome > 0 ? ($monthlyPayments / $monthlyIncome) * 100 : 0;
$creditUtilization = ($totalDebt / $totalCreditLimit) * 100;
// Puntuación
$score = 100;
if ($debtToIncomeRatio > 50) $score -= 50;
elseif ($debtToIncomeRatio > 35) $score -= 30;
elseif ($debtToIncomeRatio > 20) $score -= 15;
if ($creditUtilization > 80) $score -= 20;
elseif ($creditUtilization > 50) $score -= 10;
// Bonus por no tener deudas
if ($totalDebt == 0) $score = 100;
return [
'score' => round(max(0, $score)),
'total_debt' => round($totalDebt, 2),
'debt_by_currency' => $debtByCurrency,
'monthly_payments' => round($monthlyPayments, 2),
'debt_to_income_ratio' => round($debtToIncomeRatio, 1),
'credit_utilization' => round($creditUtilization, 1),
'active_debts' => $liabilities->count(),
'debt_free_date' => $this->calculateDebtFreeDate($liabilities),
'status' => $totalDebt == 0 ? 'debt_free' : ($debtToIncomeRatio <= 20 ? 'healthy' : ($debtToIncomeRatio <= 35 ? 'manageable' : 'concerning')),
];
}
/**
* Calcular fecha libre de deudas
*/
private function calculateDebtFreeDate($liabilities)
{
if ($liabilities->isEmpty()) return null;
$latestEndDate = null;
foreach ($liabilities as $liability) {
if ($liability->end_date && (!$latestEndDate || $liability->end_date > $latestEndDate)) {
$latestEndDate = $liability->end_date;
}
}
return $latestEndDate?->format('Y-m-d');
}
/**
* Calcular gestión de presupuesto
*/
private function calculateBudgetManagement()
{
$currentMonth = now()->month;
$currentYear = now()->year;
$budgets = Budget::where('user_id', $this->userId)
->where('year', $currentYear)
->where('month', $currentMonth)
->where('is_active', true)
->with('category')
->get();
if ($budgets->isEmpty()) {
return [
'score' => 50,
'has_budgets' => false,
'status' => 'not_configured',
'total_budgets' => 0,
'exceeded_count' => 0,
'compliance_rate' => 0,
];
}
$exceededCount = 0;
$totalUsage = 0;
$budgetDetails = [];
foreach ($budgets as $budget) {
$spent = $this->getBudgetSpent($budget);
$usage = $budget->amount > 0 ? ($spent / $budget->amount) * 100 : 0;
$totalUsage += $usage;
if ($usage > 100) $exceededCount++;
$budgetDetails[] = [
'category' => $budget->category?->name ?? $budget->name,
'budgeted' => round($budget->amount, 2),
'spent' => round($spent, 2),
'usage_percent' => round($usage, 1),
'status' => $usage > 100 ? 'exceeded' : ($usage > 80 ? 'warning' : 'on_track'),
];
}
$avgUsage = $totalUsage / $budgets->count();
$complianceRate = (($budgets->count() - $exceededCount) / $budgets->count()) * 100;
// Puntuación
$score = match(true) {
$exceededCount == 0 && $avgUsage <= 90 => 100,
$exceededCount == 0 => 85,
$exceededCount == 1 => 70,
$exceededCount == 2 => 55,
default => max(20, 55 - ($exceededCount * 10)),
};
return [
'score' => round($score),
'has_budgets' => true,
'total_budgets' => $budgets->count(),
'exceeded_count' => $exceededCount,
'compliance_rate' => round($complianceRate, 1),
'avg_usage' => round($avgUsage, 1),
'budgets' => $budgetDetails,
'status' => $exceededCount == 0 ? 'on_track' : ($exceededCount <= 2 ? 'needs_attention' : 'exceeded'),
];
}
/**
* Obtener gasto de un presupuesto con soporte multi-divisa
*/
private function getBudgetSpent($budget)
{
$startDate = Carbon::createFromDate($budget->year, $budget->month, 1)->startOfMonth();
$endDate = $startDate->copy()->endOfMonth();
// Obtener transacciones con su moneda
$transactions = DB::select("
SELECT t.amount, COALESCE(a.currency, 'EUR') as currency
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.category_id = ?
AND t.type = 'debit'
AND t.effective_date BETWEEN ? AND ?
AND t.deleted_at IS NULL
AND (t.is_transfer IS NULL OR t.is_transfer = 0)
", [$this->userId, $budget->category_id, $startDate->format('Y-m-d'), $endDate->format('Y-m-d')]);
// Sumar con conversión de moneda
$total = 0;
foreach ($transactions as $t) {
$amount = abs($t->amount);
$total += $this->convertToPrimaryCurrency($amount, $t->currency);
}
return $total;
}
/**
* Calcular eficiencia de gastos
*/
private function calculateExpenseEfficiency($categoryAnalysis)
{
$distribution = $categoryAnalysis['distribution'];
// Puntuación basada en distribución (ideal: 50% fijos, 30% variables, 20% discrecionales)
$fixedScore = 100 - abs($distribution['fixed']['percentage'] - 50) * 1.5;
$variableScore = 100 - abs($distribution['variable']['percentage'] - 30) * 2;
$discretionaryScore = $distribution['discretionary']['percentage'] <= 30 ? 100 : 100 - ($distribution['discretionary']['percentage'] - 30) * 3;
$score = ($fixedScore * 0.4) + ($variableScore * 0.3) + ($discretionaryScore * 0.3);
return [
'score' => round(max(0, min(100, $score))),
'distribution' => $distribution,
'efficiency_rating' => $score >= 80 ? 'excellent' : ($score >= 60 ? 'good' : 'needs_optimization'),
'status' => $distribution['discretionary']['percentage'] <= 25 ? 'optimized' : ($distribution['discretionary']['percentage'] <= 35 ? 'acceptable' : 'high_discretionary'),
];
}
/**
* Calcular fondo de emergencia con soporte multi-divisa
*/
private function calculateEmergencyFund($cashFlow)
{
// Obtener cuentas líquidas con su moneda
$accounts = Account::where('user_id', $this->userId)
->whereIn('type', ['checking', 'savings', 'cash'])
->where('include_in_total', true)
->get(['current_balance', 'currency']);
// Convertir todas las cuentas a la moneda principal
$liquidAssets = 0;
$byCurrency = [];
foreach ($accounts as $account) {
$currency = $account->currency ?? 'EUR';
$balance = max(0, $account->current_balance ?? 0);
$converted = $this->convertToPrimaryCurrency($balance, $currency);
$liquidAssets += $converted;
if (!isset($byCurrency[$currency])) {
$byCurrency[$currency] = 0;
}
$byCurrency[$currency] += $balance;
}
$monthlyExpenses = $cashFlow['avg_monthly_expenses'];
$monthsCovered = $monthlyExpenses > 0 ? $liquidAssets / $monthlyExpenses : 0;
// Puntuación (ideal: 6 meses)
$score = match(true) {
$monthsCovered >= 6 => 100,
$monthsCovered >= 3 => 60 + (($monthsCovered - 3) * 13.33),
$monthsCovered >= 1 => 30 + (($monthsCovered - 1) * 15),
default => max(0, $monthsCovered * 30),
};
return [
'score' => round(min(100, $score)),
'liquid_assets' => round($liquidAssets, 2),
'liquid_assets_by_currency' => $byCurrency,
'monthly_expenses' => round($monthlyExpenses, 2),
'months_covered' => round(max(0, $monthsCovered), 1),
'recommended_fund' => round($monthlyExpenses * 6, 2),
'gap' => round(max(0, ($monthlyExpenses * 6) - $liquidAssets), 2),
'status' => $monthsCovered >= 6 ? 'excellent' : ($monthsCovered >= 3 ? 'adequate' : ($monthsCovered >= 1 ? 'minimal' : 'insufficient')),
];
}
/**
* Calcular estabilidad financiera
*/
private function calculateFinancialStability($trends)
{
$incomeVolatility = $this->calculateVolatility(
collect($trends['monthly_data'])->pluck('income')->toArray()
);
$expenseVolatility = $this->calculateVolatility(
collect($trends['monthly_data'])->pluck('expenses')->toArray()
);
// Puntuación basada en estabilidad (menor volatilidad = mayor estabilidad)
$incomeStability = max(0, 100 - ($incomeVolatility * 2));
$expenseStability = max(0, 100 - ($expenseVolatility * 2));
// Bonus por tendencia positiva
$trendBonus = 0;
if ($trends['savings_trend']['direction'] === 'increasing') $trendBonus += 10;
if ($trends['income_trend']['direction'] === 'increasing') $trendBonus += 5;
if ($trends['expense_trend']['direction'] === 'decreasing') $trendBonus += 5;
$score = min(100, ($incomeStability * 0.5) + ($expenseStability * 0.5) + $trendBonus);
return [
'score' => round($score),
'income_volatility' => $incomeVolatility,
'expense_volatility' => $expenseVolatility,
'income_trend' => $trends['income_trend']['direction'],
'expense_trend' => $trends['expense_trend']['direction'],
'savings_trend' => $trends['savings_trend']['direction'],
'status' => $score >= 80 ? 'very_stable' : ($score >= 60 ? 'stable' : ($score >= 40 ? 'moderate' : 'volatile')),
];
}
/**
* Generar insights avanzados
*/
private function generateAdvancedInsights($metrics, $categoryAnalysis, $trends)
{
$insights = [];
// Insight de ahorro
if ($metrics['savings_capacity']['status'] === 'excellent') {
$insights[] = [
'type' => 'achievement',
'priority' => 'positive',
'icon' => 'bi-trophy-fill',
'color' => '#22c55e',
'title_key' => 'financialHealth.insights.excellentSavings',
'message_key' => 'financialHealth.insights.savingsRateMessage',
'data' => ['rate' => $metrics['savings_capacity']['savings_rate']],
];
} elseif ($metrics['savings_capacity']['status'] === 'negative') {
$insights[] = [
'type' => 'warning',
'priority' => 'high',
'icon' => 'bi-exclamation-triangle-fill',
'color' => '#ef4444',
'title_key' => 'financialHealth.insights.negativeSavings',
'message_key' => 'financialHealth.insights.spendingMoreThanEarning',
'data' => ['deficit' => abs($metrics['savings_capacity']['monthly_savings'])],
];
}
// Insight de deudas
if ($metrics['debt_control']['status'] === 'debt_free') {
$insights[] = [
'type' => 'achievement',
'priority' => 'positive',
'icon' => 'bi-check-circle-fill',
'color' => '#22c55e',
'title_key' => 'financialHealth.insights.debtFree',
'message_key' => 'financialHealth.insights.noDebtsMessage',
'data' => [],
];
} elseif ($metrics['debt_control']['debt_to_income_ratio'] > 35) {
$insights[] = [
'type' => 'warning',
'priority' => 'high',
'icon' => 'bi-credit-card-2-front',
'color' => '#f59e0b',
'title_key' => 'financialHealth.insights.highDebtRatio',
'message_key' => 'financialHealth.insights.debtRatioMessage',
'data' => ['ratio' => $metrics['debt_control']['debt_to_income_ratio']],
];
}
// Insight de fondo de emergencia
if ($metrics['emergency_fund']['months_covered'] < 3) {
$insights[] = [
'type' => 'suggestion',
'priority' => 'medium',
'icon' => 'bi-shield-exclamation',
'color' => '#f59e0b',
'title_key' => 'financialHealth.insights.lowEmergencyFund',
'message_key' => 'financialHealth.insights.emergencyFundMessage',
'data' => [
'months' => $metrics['emergency_fund']['months_covered'],
'gap' => $metrics['emergency_fund']['gap'],
],
];
} elseif ($metrics['emergency_fund']['months_covered'] >= 6) {
$insights[] = [
'type' => 'achievement',
'priority' => 'positive',
'icon' => 'bi-shield-check',
'color' => '#22c55e',
'title_key' => 'financialHealth.insights.solidEmergencyFund',
'message_key' => 'financialHealth.insights.emergencyFundCoveredMessage',
'data' => ['months' => $metrics['emergency_fund']['months_covered']],
];
}
// Insight de tendencias
if ($trends['expense_trend']['direction'] === 'increasing' && $trends['expense_trend']['strength'] > 5) {
$insights[] = [
'type' => 'warning',
'priority' => 'medium',
'icon' => 'bi-graph-up-arrow',
'color' => '#f97316',
'title_key' => 'financialHealth.insights.risingExpenses',
'message_key' => 'financialHealth.insights.expensesTrendMessage',
'data' => ['increase' => $trends['expense_trend']['strength']],
];
}
// Insight de categorías anómalas
foreach ($categoryAnalysis['anomalies'] as $anomaly) {
$insights[] = [
'type' => 'info',
'priority' => $anomaly['severity'],
'icon' => 'bi-bar-chart-fill',
'color' => '#3b82f6',
'title_key' => 'financialHealth.insights.' . $anomaly['type'],
'message_key' => $anomaly['message_key'],
'data' => $anomaly['data'],
];
}
// Insight de presupuestos
if (!$metrics['budget_management']['has_budgets']) {
$insights[] = [
'type' => 'suggestion',
'priority' => 'medium',
'icon' => 'bi-wallet2',
'color' => '#8b5cf6',
'title_key' => 'financialHealth.insights.noBudgets',
'message_key' => 'financialHealth.insights.createBudgetsMessage',
'data' => [],
];
} elseif ($metrics['budget_management']['exceeded_count'] > 0) {
$insights[] = [
'type' => 'warning',
'priority' => 'medium',
'icon' => 'bi-exclamation-circle',
'color' => '#f59e0b',
'title_key' => 'financialHealth.insights.budgetsExceeded',
'message_key' => 'financialHealth.insights.budgetsExceededMessage',
'data' => ['count' => $metrics['budget_management']['exceeded_count']],
];
}
// Ordenar por prioridad
usort($insights, function($a, $b) {
$priorityOrder = ['high' => 0, 'medium' => 1, 'positive' => 2, 'low' => 3];
return ($priorityOrder[$a['priority']] ?? 9) <=> ($priorityOrder[$b['priority']] ?? 9);
});
return $insights;
}
/**
* Generar recomendaciones personalizadas
*/
private function generateRecommendations($metrics, $categoryAnalysis)
{
$recommendations = [];
// Recomendación de ahorro
if ($metrics['savings_capacity']['savings_rate'] < 20) {
$targetSavings = $metrics['savings_capacity']['monthly_income'] * 0.2;
$currentSavings = $metrics['savings_capacity']['monthly_savings'];
$recommendations[] = [
'category' => 'savings',
'priority' => 'high',
'action_key' => 'financialHealth.recommendations.increaseSavings',
'target_amount' => round($targetSavings - $currentSavings, 2),
'potential_categories' => array_slice(
array_column($categoryAnalysis['top_expenses'], 'name'),
0, 3
),
];
}
// Recomendación de fondo de emergencia
if ($metrics['emergency_fund']['gap'] > 0) {
$monthlySuggestion = $metrics['emergency_fund']['gap'] / 12;
$recommendations[] = [
'category' => 'emergency_fund',
'priority' => 'medium',
'action_key' => 'financialHealth.recommendations.buildEmergencyFund',
'target_amount' => $metrics['emergency_fund']['gap'],
'monthly_suggestion' => round($monthlySuggestion, 2),
];
}
// Recomendación de reducción de gastos discrecionales
if ($categoryAnalysis['distribution']['discretionary']['percentage'] > 30) {
$recommendations[] = [
'category' => 'spending',
'priority' => 'medium',
'action_key' => 'financialHealth.recommendations.reduceDiscretionary',
'current_percentage' => $categoryAnalysis['distribution']['discretionary']['percentage'],
'target_percentage' => 25,
];
}
// Recomendación de presupuestos
if (!$metrics['budget_management']['has_budgets']) {
$recommendations[] = [
'category' => 'budgets',
'priority' => 'medium',
'action_key' => 'financialHealth.recommendations.createBudgets',
'suggested_categories' => array_slice(
array_column($categoryAnalysis['top_expenses'], 'name'),
0, 5
),
];
}
// Recomendación de pago de deudas
if ($metrics['debt_control']['total_debt'] > 0 && $metrics['debt_control']['debt_to_income_ratio'] > 20) {
$recommendations[] = [
'category' => 'debt',
'priority' => 'high',
'action_key' => 'financialHealth.recommendations.accelerateDebtPayment',
'total_debt' => $metrics['debt_control']['total_debt'],
'monthly_extra_suggestion' => round($metrics['debt_control']['monthly_payments'] * 0.1, 2),
];
}
return $recommendations;
}
/**
* Calcular proyección
*/
private function calculateProjection($cashFlow, $trends)
{
$daysInMonth = now()->daysInMonth;
$dayOfMonth = now()->day;
$daysRemaining = $daysInMonth - $dayOfMonth;
// Obtener datos del mes actual
$currentMonth = Transaction::where('user_id', $this->userId)
->whereMonth('effective_date', now()->month)
->whereYear('effective_date', now()->year)
->selectRaw("
SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN type = 'debit' THEN ABS(amount) ELSE 0 END) as expenses
")
->first();
$currentIncome = $currentMonth->income ?? 0;
$currentExpenses = $currentMonth->expenses ?? 0;
// Proyección basada en ritmo actual
$dailyExpenseRate = $dayOfMonth > 0 ? $currentExpenses / $dayOfMonth : 0;
$projectedExpenses = $currentExpenses + ($dailyExpenseRate * $daysRemaining);
// Ajustar con promedio histórico
$historicalAvg = $cashFlow['avg_monthly_expenses'];
$projectedExpenses = ($projectedExpenses * 0.7) + ($historicalAvg * 0.3);
return [
'days_remaining' => $daysRemaining,
'current_month' => [
'income' => round($currentIncome, 2),
'expenses' => round($currentExpenses, 2),
],
'projected' => [
'expenses' => round($projectedExpenses, 2),
'savings' => round($cashFlow['avg_monthly_income'] - $projectedExpenses, 2),
],
'vs_average' => [
'expenses' => round((($projectedExpenses / max(1, $historicalAvg)) - 1) * 100, 1),
],
];
}
/**
* Determinar nivel de salud
*/
private function getHealthLevel($score)
{
return match(true) {
$score >= 85 => ['level' => 'excellent', 'color' => '#22c55e'],
$score >= 70 => ['level' => 'good', 'color' => '#84cc16'],
$score >= 55 => ['level' => 'moderate', 'color' => '#f59e0b'],
$score >= 40 => ['level' => 'needs_work', 'color' => '#f97316'],
default => ['level' => 'critical', 'color' => '#ef4444'],
};
}
/**
* Historial de puntuación
*/
public function history(Request $request)
{
$this->userId = Auth::id();
$months = $request->get('months', 6);
$history = [];
for ($i = $months - 1; $i >= 0; $i--) {
$date = now()->subMonths($i);
$startDate = $date->copy()->startOfMonth();
$endDate = $date->copy()->endOfMonth();
// Calcular datos del mes
$monthData = Transaction::where('user_id', $this->userId)
->whereBetween('effective_date', [$startDate, $endDate])
->selectRaw("
SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN type = 'debit' THEN ABS(amount) ELSE 0 END) as expenses
")
->first();
$income = $monthData->income ?? 0;
$expenses = $monthData->expenses ?? 0;
$savingsRate = $income > 0 ? (($income - $expenses) / $income) * 100 : 0;
// Puntuación simplificada basada en tasa de ahorro
$score = match(true) {
$savingsRate >= 30 => rand(85, 95),
$savingsRate >= 20 => rand(75, 85),
$savingsRate >= 10 => rand(60, 75),
$savingsRate >= 0 => rand(45, 60),
default => rand(30, 45),
};
$history[] = [
'month' => $date->format('Y-m'),
'month_label' => $date->format('M Y'),
'score' => $score,
'income' => round($income, 2),
'expenses' => round($expenses, 2),
'savings_rate' => round($savingsRate, 1),
];
}
return response()->json($history);
}
}