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

1128 lines
45 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
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;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
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 con multi-divisa
*/
public function summary(Request $request)
{
$this->init();
$year = $request->get('year', now()->year);
// Datos por moneda
$yearData = DB::select("
SELECT
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND YEAR(t.effective_date) = ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(a.currency, 'EUR')
", [$this->userId, $year]);
$lastYearData = DB::select("
SELECT
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND YEAR(t.effective_date) = ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(a.currency, 'EUR')
", [$this->userId, $year - 1]);
// Convertir y sumar
$currentIncome = 0; $currentExpense = 0;
$byCurrency = [];
foreach ($yearData as $row) {
$currentIncome += $this->convertToPrimaryCurrency($row->income, $row->currency);
$currentExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency);
$byCurrency[$row->currency] = [
'income' => round($row->income, 2),
'expense' => round($row->expense, 2),
'balance' => round($row->income - $row->expense, 2),
];
}
$previousIncome = 0; $previousExpense = 0;
foreach ($lastYearData as $row) {
$previousIncome += $this->convertToPrimaryCurrency($row->income, $row->currency);
$previousExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency);
}
return response()->json([
'year' => $year,
'currency' => $this->primaryCurrency,
'current' => [
'income' => round($currentIncome, 2),
'expense' => round($currentExpense, 2),
'balance' => round($currentIncome - $currentExpense, 2),
],
'previous' => [
'income' => round($previousIncome, 2),
'expense' => round($previousExpense, 2),
'balance' => round($previousIncome - $previousExpense, 2),
],
'variation' => [
'income' => $previousIncome > 0 ? round((($currentIncome - $previousIncome) / $previousIncome) * 100, 1) : 0,
'expense' => $previousExpense > 0 ? round((($currentExpense - $previousExpense) / $previousExpense) * 100, 1) : 0,
],
'by_currency' => $byCurrency,
]);
}
/**
* Reporte por categorías con multi-divisa
*/
public function byCategory(Request $request)
{
$this->init();
$startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d'));
$endDate = $request->get('end_date', now()->format('Y-m-d'));
$type = $request->get('type', 'debit');
$groupByParent = $request->get('group_by_parent', false);
// Si se quiere agrupar por categoría padre, obtenemos el nombre del padre
if ($groupByParent) {
$data = DB::select("
SELECT
COALESCE(c.parent_id, c.id) as category_id,
COALESCE(cp.name, c.name) as category_name,
COALESCE(cp.icon, c.icon) as icon,
COALESCE(cp.color, c.color) as color,
COALESCE(a.currency, 'EUR') as currency,
SUM(ABS(t.amount)) as total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN categories cp ON c.parent_id = cp.id
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND t.type = ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(c.parent_id, c.id), COALESCE(cp.name, c.name), COALESCE(cp.icon, c.icon), COALESCE(cp.color, c.color), COALESCE(a.currency, 'EUR')
ORDER BY total DESC
", [$this->userId, $startDate, $endDate, $type]);
} else {
// Sin agrupar: cada subcategoría se muestra individualmente
$data = DB::select("
SELECT
c.id as category_id,
c.name as category_name,
c.icon,
c.color,
COALESCE(a.currency, 'EUR') as currency,
SUM(ABS(t.amount)) as total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND t.type = ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY c.id, c.name, c.icon, c.color, COALESCE(a.currency, 'EUR')
ORDER BY total DESC
", [$this->userId, $startDate, $endDate, $type]);
}
// Agrupar por categoría y convertir monedas
$byCategory = [];
foreach ($data as $row) {
$catId = $row->category_id ?? 0;
if (!isset($byCategory[$catId])) {
$byCategory[$catId] = [
'category_id' => $catId,
'category_name' => $row->category_name ?? 'Sin categoría',
'icon' => $row->icon ?? 'bi-tag',
'color' => $row->color ?? '#6b7280',
'total_converted' => 0,
'by_currency' => [],
];
}
$byCategory[$catId]['by_currency'][$row->currency] = round($row->total, 2);
$byCategory[$catId]['total_converted'] += $this->convertToPrimaryCurrency($row->total, $row->currency);
}
// Ordenar y calcular porcentajes
usort($byCategory, fn($a, $b) => $b['total_converted'] <=> $a['total_converted']);
$grandTotal = array_sum(array_column($byCategory, 'total_converted'));
$result = array_map(function($cat) use ($grandTotal) {
$cat['total'] = round($cat['total_converted'], 2);
$cat['percentage'] = $grandTotal > 0 ? round(($cat['total_converted'] / $grandTotal) * 100, 1) : 0;
unset($cat['total_converted']);
return $cat;
}, $byCategory);
return response()->json([
'data' => array_values($result),
'total' => round($grandTotal, 2),
'currency' => $this->primaryCurrency,
'period' => ['start' => $startDate, 'end' => $endDate],
]);
}
/**
* Reporte por centro de costos
*/
public function byCostCenter(Request $request)
{
$this->init();
$startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d'));
$endDate = $request->get('end_date', now()->format('Y-m-d'));
$data = DB::select("
SELECT
cc.id as cost_center_id,
cc.name as cost_center_name,
cc.color,
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN cost_centers cc ON t.cost_center_id = cc.id
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
AND t.cost_center_id IS NOT NULL
GROUP BY cc.id, cc.name, cc.color, COALESCE(a.currency, 'EUR')
ORDER BY expense DESC
", [$this->userId, $startDate, $endDate]);
// Agrupar por centro de costo
$byCostCenter = [];
foreach ($data as $row) {
$ccId = $row->cost_center_id;
if (!isset($byCostCenter[$ccId])) {
$byCostCenter[$ccId] = [
'id' => $ccId,
'name' => $row->cost_center_name,
'color' => $row->color ?? '#6b7280',
'income_converted' => 0,
'expense_converted' => 0,
'by_currency' => [],
];
}
$byCostCenter[$ccId]['by_currency'][$row->currency] = [
'income' => round($row->income, 2),
'expense' => round($row->expense, 2),
];
$byCostCenter[$ccId]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency);
$byCostCenter[$ccId]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency);
}
$result = array_map(function($cc) {
return [
'id' => $cc['id'],
'name' => $cc['name'],
'color' => $cc['color'],
'income' => round($cc['income_converted'], 2),
'expense' => round($cc['expense_converted'], 2),
'balance' => round($cc['income_converted'] - $cc['expense_converted'], 2),
'by_currency' => $cc['by_currency'],
];
}, $byCostCenter);
usort($result, fn($a, $b) => $b['expense'] <=> $a['expense']);
return response()->json([
'data' => array_values($result),
'currency' => $this->primaryCurrency,
'total_income' => round(array_sum(array_column($result, 'income')), 2),
'total_expense' => round(array_sum(array_column($result, 'expense')), 2),
]);
}
/**
* Reporte de evolución mensual con multi-divisa
*/
public function monthlyEvolution(Request $request)
{
$this->init();
$months = $request->get('months', 12);
$startDate = now()->subMonths($months)->startOfMonth()->format('Y-m-d');
$data = DB::select("
SELECT
DATE_FORMAT(t.effective_date, '%Y-%m') as month,
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date >= ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY DATE_FORMAT(t.effective_date, '%Y-%m'), COALESCE(a.currency, 'EUR')
ORDER BY month
", [$this->userId, $startDate]);
// Agrupar por mes y convertir
$byMonth = [];
foreach ($data as $row) {
if (!isset($byMonth[$row->month])) {
$byMonth[$row->month] = [
'month' => $row->month,
'income_converted' => 0,
'expense_converted' => 0,
'by_currency' => [],
];
}
$byMonth[$row->month]['by_currency'][$row->currency] = [
'income' => round($row->income, 2),
'expense' => round($row->expense, 2),
];
$byMonth[$row->month]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency);
$byMonth[$row->month]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency);
}
$result = array_map(function($item) {
$balance = $item['income_converted'] - $item['expense_converted'];
$savingsRate = $item['income_converted'] > 0 ? round(($balance / $item['income_converted']) * 100, 1) : 0;
return [
'month' => $item['month'],
'month_label' => Carbon::parse($item['month'] . '-01')->isoFormat('MMM YYYY'),
'income' => round($item['income_converted'], 2),
'expense' => round($item['expense_converted'], 2),
'balance' => round($balance, 2),
'savings_rate' => $savingsRate,
'by_currency' => $item['by_currency'],
];
}, $byMonth);
$resultArray = array_values($result);
$avgIncome = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'income')) / count($resultArray) : 0;
$avgExpense = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'expense')) / count($resultArray) : 0;
$avgBalance = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'balance')) / count($resultArray) : 0;
$avgSavings = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'savings_rate')) / count($resultArray) : 0;
return response()->json([
'data' => $resultArray,
'currency' => $this->primaryCurrency,
'averages' => [
'income' => round($avgIncome, 2),
'expense' => round($avgExpense, 2),
'balance' => round($avgBalance, 2),
'savings_rate' => round($avgSavings, 1),
],
]);
}
/**
* Reporte de gastos por día de la semana con multi-divisa
*/
public function byDayOfWeek(Request $request)
{
$this->init();
$months = $request->get('months', 6);
$startDate = now()->subMonths($months)->format('Y-m-d');
$data = DB::select("
SELECT
DAYOFWEEK(t.effective_date) as day_num,
COALESCE(a.currency, 'EUR') as currency,
COUNT(*) as count,
SUM(ABS(t.amount)) as total
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.type = 'debit'
AND t.effective_date >= ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY DAYOFWEEK(t.effective_date), COALESCE(a.currency, 'EUR')
", [$this->userId, $startDate]);
// Agrupar por día
$byDay = [];
for ($i = 1; $i <= 7; $i++) {
$byDay[$i] = ['count' => 0, 'total_converted' => 0, 'by_currency' => []];
}
foreach ($data as $row) {
$byDay[$row->day_num]['count'] += $row->count;
$byDay[$row->day_num]['total_converted'] += $this->convertToPrimaryCurrency($row->total, $row->currency);
if (!isset($byDay[$row->day_num]['by_currency'][$row->currency])) {
$byDay[$row->day_num]['by_currency'][$row->currency] = 0;
}
$byDay[$row->day_num]['by_currency'][$row->currency] += round($row->total, 2);
}
// Mapeo de días que el frontend traducirá
$dayKeys = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
$result = [];
foreach ($byDay as $dayNum => $info) {
$result[] = [
'day_key' => $dayKeys[$dayNum - 1],
'day_num' => $dayNum,
'count' => $info['count'],
'total' => round($info['total_converted'], 2),
'average' => $info['count'] > 0 ? round($info['total_converted'] / $info['count'], 2) : 0,
'by_currency' => $info['by_currency'],
];
}
return response()->json([
'data' => $result,
'currency' => $this->primaryCurrency,
]);
}
/**
* Top gastos con multi-divisa
*/
public function topExpenses(Request $request)
{
$this->init();
$startDate = $request->get('start_date', now()->startOfMonth()->format('Y-m-d'));
$endDate = $request->get('end_date', now()->format('Y-m-d'));
$limit = $request->get('limit', 20);
$data = DB::select("
SELECT
t.id,
t.description,
ABS(t.amount) as amount,
t.effective_date,
COALESCE(a.currency, 'EUR') as currency,
c.name as category_name,
c.icon as category_icon,
c.color as category_color,
a.name as account_name,
cc.name as cost_center_name
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN cost_centers cc ON t.cost_center_id = cc.id
WHERE t.user_id = ?
AND t.type = 'debit'
AND t.effective_date BETWEEN ? AND ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
ORDER BY ABS(t.amount) DESC
LIMIT ?
", [$this->userId, $startDate, $endDate, $limit]);
$totalConverted = 0;
$result = array_map(function($row) use (&$totalConverted) {
$converted = $this->convertToPrimaryCurrency($row->amount, $row->currency);
$totalConverted += $converted;
return [
'id' => $row->id,
'description' => $row->description,
'amount' => round($row->amount, 2),
'amount_converted' => round($converted, 2),
'currency' => $row->currency,
'date' => $row->effective_date,
'category' => $row->category_name,
'category_icon' => $row->category_icon,
'category_color' => $row->category_color,
'account' => $row->account_name,
'cost_center' => $row->cost_center_name,
];
}, $data);
return response()->json([
'data' => $result,
'total' => round($totalConverted, 2),
'currency' => $this->primaryCurrency,
'period' => ['start' => $startDate, 'end' => $endDate],
]);
}
/**
* Comparativa de períodos con multi-divisa
*/
public function comparePeriods(Request $request)
{
$this->init();
$period1Start = $request->get('period1_start');
$period1End = $request->get('period1_end');
$period2Start = $request->get('period2_start');
$period2End = $request->get('period2_end');
if (!$period1Start) {
$period1Start = now()->startOfMonth()->format('Y-m-d');
$period1End = now()->endOfMonth()->format('Y-m-d');
$period2Start = now()->subMonth()->startOfMonth()->format('Y-m-d');
$period2End = now()->subMonth()->endOfMonth()->format('Y-m-d');
}
$getPeriodData = function($start, $end) {
$data = DB::select("
SELECT
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense,
COUNT(*) as transactions
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(a.currency, 'EUR')
", [$this->userId, $start, $end]);
$income = 0;
$expense = 0;
$transactions = 0;
$byCurrency = [];
foreach ($data as $row) {
$income += $this->convertToPrimaryCurrency($row->income, $row->currency);
$expense += $this->convertToPrimaryCurrency($row->expense, $row->currency);
$transactions += $row->transactions;
$byCurrency[$row->currency] = [
'income' => round($row->income, 2),
'expense' => round($row->expense, 2),
];
}
return [
'income' => round($income, 2),
'expense' => round($expense, 2),
'balance' => round($income - $expense, 2),
'transactions' => $transactions,
'by_currency' => $byCurrency,
];
};
$period1Data = $getPeriodData($period1Start, $period1End);
$period2Data = $getPeriodData($period2Start, $period2End);
$calcVariation = function($current, $previous) {
return $previous > 0 ? round((($current - $previous) / $previous) * 100, 1) : 0;
};
return response()->json([
'period1' => array_merge([
'label' => Carbon::parse($period1Start)->isoFormat('MMM YYYY'),
'start' => $period1Start,
'end' => $period1End,
], $period1Data),
'period2' => array_merge([
'label' => Carbon::parse($period2Start)->isoFormat('MMM YYYY'),
'start' => $period2Start,
'end' => $period2End,
], $period2Data),
'variation' => [
'income' => $calcVariation($period1Data['income'], $period2Data['income']),
'expense' => $calcVariation($period1Data['expense'], $period2Data['expense']),
'balance' => $calcVariation($period1Data['balance'], $period2Data['balance']),
],
'currency' => $this->primaryCurrency,
]);
}
/**
* Reporte de cuentas con multi-divisa
*/
public function accountsReport(Request $request)
{
$this->init();
$accounts = Account::where('user_id', $this->userId)->get();
$result = [];
$totalBalanceConverted = 0;
foreach ($accounts as $account) {
// Movimientos del mes (excluyendo transferencias)
$monthStats = DB::select("
SELECT
SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
FROM transactions
WHERE account_id = ?
AND MONTH(effective_date) = ?
AND YEAR(effective_date) = ?
AND deleted_at IS NULL
AND {$this->excludeTransfers()}
", [$account->id, now()->month, now()->year]);
$stats = $monthStats[0] ?? (object)['income' => 0, 'expense' => 0];
// Últimas transacciones
$recent = DB::select("
SELECT id, description, amount, type, effective_date
FROM transactions
WHERE account_id = ?
AND deleted_at IS NULL
ORDER BY effective_date DESC
LIMIT 5
", [$account->id]);
$balanceConverted = $this->convertToPrimaryCurrency($account->current_balance, $account->currency);
if ($account->include_in_total) {
$totalBalanceConverted += $balanceConverted;
}
$result[] = [
'id' => $account->id,
'name' => $account->name,
'type' => $account->type,
'currency' => $account->currency,
'balance' => round($account->current_balance, 2),
'balance_converted' => round($balanceConverted, 2),
'include_in_total' => $account->include_in_total,
'month_income' => round($stats->income ?? 0, 2),
'month_expense' => round($stats->expense ?? 0, 2),
'recent_activity' => array_map(function($t) {
return [
'id' => $t->id,
'description' => $t->description,
'amount' => $t->amount,
'type' => $t->type,
'date' => $t->effective_date,
];
}, $recent),
];
}
return response()->json([
'accounts' => $result,
'currency' => $this->primaryCurrency,
'summary' => [
'total_accounts' => count($accounts),
'total_balance_converted' => round($totalBalanceConverted, 2),
],
]);
}
/**
* Proyección de gastos con multi-divisa
*/
public function projection(Request $request)
{
$this->init();
$months = $request->get('months', 3);
$startDate = now()->subMonths($months)->startOfMonth()->format('Y-m-d');
$endMonthStart = now()->startOfMonth()->format('Y-m-d');
// Histórico por divisa
$historical = DB::select("
SELECT
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) / ? as monthly_income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) / ? as monthly_expense
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date >= ?
AND t.effective_date < ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(a.currency, 'EUR')
", [$months, $months, $this->userId, $startDate, $endMonthStart]);
$histIncome = 0;
$histExpense = 0;
foreach ($historical as $row) {
$histIncome += $this->convertToPrimaryCurrency($row->monthly_income, $row->currency);
$histExpense += $this->convertToPrimaryCurrency($row->monthly_expense, $row->currency);
}
// Mes actual por divisa
$current = DB::select("
SELECT
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND MONTH(t.effective_date) = ?
AND YEAR(t.effective_date) = ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(a.currency, 'EUR')
", [$this->userId, now()->month, now()->year]);
$currIncome = 0;
$currExpense = 0;
foreach ($current as $row) {
$currIncome += $this->convertToPrimaryCurrency($row->income, $row->currency);
$currExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency);
}
$daysElapsed = max(1, now()->day);
$daysInMonth = now()->daysInMonth;
$daysRemaining = $daysInMonth - $daysElapsed;
$projectedExpense = ($currExpense / $daysElapsed) * $daysInMonth;
$projectedIncome = ($currIncome / $daysElapsed) * $daysInMonth;
return response()->json([
'historical_average' => [
'income' => round($histIncome, 2),
'expense' => round($histExpense, 2),
],
'current_month' => [
'income' => round($currIncome, 2),
'expense' => round($currExpense, 2),
'days_elapsed' => $daysElapsed,
'days_remaining' => $daysRemaining,
],
'projection' => [
'income' => round($projectedIncome, 2),
'expense' => round($projectedExpense, 2),
'balance' => round($projectedIncome - $projectedExpense, 2),
],
'vs_average' => [
'income' => $histIncome > 0 ? round((($projectedIncome - $histIncome) / $histIncome) * 100, 1) : 0,
'expense' => $histExpense > 0 ? round((($projectedExpense - $histExpense) / $histExpense) * 100, 1) : 0,
],
'currency' => $this->primaryCurrency,
]);
}
/**
* Reporte de recurrencias con multi-divisa
*/
public function recurringReport(Request $request)
{
$this->init();
$templates = RecurringTemplate::where('user_id', $this->userId)
->where('is_active', true)
->with(['category', 'account'])
->get();
$monthlyIncomeConverted = 0;
$monthlyExpenseConverted = 0;
$byCurrency = [];
$result = $templates->map(function($t) use (&$monthlyIncomeConverted, &$monthlyExpenseConverted, &$byCurrency) {
$currency = $t->account ? $t->account->currency : 'EUR';
$amount = abs($t->amount);
$converted = $this->convertToPrimaryCurrency($amount, $currency);
if (!isset($byCurrency[$currency])) {
$byCurrency[$currency] = ['income' => 0, 'expense' => 0];
}
if ($t->type === 'credit') {
$monthlyIncomeConverted += $converted;
$byCurrency[$currency]['income'] += $amount;
} else {
$monthlyExpenseConverted += $converted;
$byCurrency[$currency]['expense'] += $amount;
}
return [
'id' => $t->id,
'description' => $t->description,
'amount' => $amount,
'amount_converted' => round($converted, 2),
'currency' => $currency,
'type' => $t->type,
'frequency' => $t->frequency,
'category' => $t->category ? $t->category->name : null,
'category_icon' => $t->category ? $t->category->icon : null,
'category_color' => $t->category ? $t->category->color : null,
'next_date' => $t->next_occurrence_date,
'account' => $t->account ? $t->account->name : null,
];
});
return response()->json([
'templates' => $result,
'currency' => $this->primaryCurrency,
'summary' => [
'total_recurring' => $templates->count(),
'monthly_income' => round($monthlyIncomeConverted, 2),
'monthly_expense' => round($monthlyExpenseConverted, 2),
'net_recurring' => round($monthlyIncomeConverted - $monthlyExpenseConverted, 2),
'by_currency' => $byCurrency,
],
]);
}
/**
* Reporte de pasivos/deudas
*/
public function liabilities(Request $request)
{
$this->init();
$liabilities = LiabilityAccount::where('user_id', $this->userId)
->with(['installments' => function($q) {
$q->orderBy('due_date');
}])
->get();
$totalDebtConverted = 0;
$totalPaidConverted = 0;
$totalPendingConverted = 0;
$result = $liabilities->map(function($l) use (&$totalDebtConverted, &$totalPaidConverted, &$totalPendingConverted) {
$currency = $l->currency ?? 'EUR';
$totalAmount = $l->total_amount ?? 0;
$paidAmount = $l->installments->where('status', 'paid')->sum('amount');
$pendingAmount = $l->installments->where('status', '!=', 'paid')->sum('amount');
$totalDebtConverted += $this->convertToPrimaryCurrency($totalAmount, $currency);
$totalPaidConverted += $this->convertToPrimaryCurrency($paidAmount, $currency);
$totalPendingConverted += $this->convertToPrimaryCurrency($pendingAmount, $currency);
// Próxima cuota pendiente
$nextInstallment = $l->installments->where('status', '!=', 'paid')->sortBy('due_date')->first();
// Cuotas vencidas
$overdueInstallments = $l->installments
->where('status', '!=', 'paid')
->where('due_date', '<', now()->format('Y-m-d'))
->count();
return [
'id' => $l->id,
'name' => $l->name,
'type' => $l->type,
'currency' => $currency,
'total_amount' => round($totalAmount, 2),
'paid_amount' => round($paidAmount, 2),
'pending_amount' => round($pendingAmount, 2),
'progress' => $totalAmount > 0 ? round(($paidAmount / $totalAmount) * 100, 1) : 0,
'total_installments' => $l->installments->count(),
'paid_installments' => $l->installments->where('status', 'paid')->count(),
'overdue_installments' => $overdueInstallments,
'next_installment' => $nextInstallment ? [
'amount' => round($nextInstallment->amount, 2),
'due_date' => $nextInstallment->due_date,
'is_overdue' => $nextInstallment->due_date < now()->format('Y-m-d'),
] : null,
'start_date' => $l->start_date,
'end_date' => $l->end_date,
];
});
return response()->json([
'data' => $result,
'currency' => $this->primaryCurrency,
'summary' => [
'total_liabilities' => $liabilities->count(),
'total_debt' => round($totalDebtConverted, 2),
'total_paid' => round($totalPaidConverted, 2),
'total_pending' => round($totalPendingConverted, 2),
'progress' => $totalDebtConverted > 0 ? round(($totalPaidConverted / $totalDebtConverted) * 100, 1) : 0,
],
]);
}
/**
* Transacciones futuras programadas
*/
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),
]);
}
}