- Reescrito futureTransactions() com 3 fontes de dados: * Transações pending/scheduled (usando planned_date) * Cuotas de passivos pendentes * Projeções de recorrências ativas - Adicionados helpers getNextRecurrenceDates() e advanceToNextOccurrence() - Corrigida query SQL: removida referência c.name inexistente - overdueTransactions() inclui cuotas e transações vencidas - Deploy via script oficial deploy.sh Closes: endpoints /api/reports/future-transactions e /api/reports/overdue
1392 lines
55 KiB
PHP
1392 lines
55 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
|
|
* Incluye: transacciones pending/scheduled, cuotas de pasivos, y proyecciones de recurrencias
|
|
*/
|
|
public function futureTransactions(Request $request)
|
|
{
|
|
$this->init();
|
|
$days = (int) $request->get('days', 30);
|
|
|
|
$endDate = now()->addDays($days)->format('Y-m-d');
|
|
$today = now()->format('Y-m-d');
|
|
|
|
$result = [];
|
|
$totalIncomeConverted = 0;
|
|
$totalExpenseConverted = 0;
|
|
|
|
// 1. Transacciones pendientes/scheduled (usando planned_date)
|
|
$pendingTransactions = DB::select("
|
|
SELECT
|
|
t.id,
|
|
t.description,
|
|
COALESCE(t.planned_amount, t.amount) as amount,
|
|
t.type,
|
|
t.planned_date as date,
|
|
t.status,
|
|
COALESCE(a.currency, 'EUR') as currency,
|
|
a.name as account_name,
|
|
c.name as category_name,
|
|
c.icon as category_icon,
|
|
cc.name as cost_center_name
|
|
FROM transactions t
|
|
LEFT JOIN accounts a ON t.account_id = a.id
|
|
LEFT JOIN categories c ON t.category_id = c.id
|
|
LEFT JOIN cost_centers cc ON t.cost_center_id = cc.id
|
|
WHERE t.user_id = ?
|
|
AND t.status IN ('pending', 'scheduled')
|
|
AND t.planned_date >= ?
|
|
AND t.planned_date <= ?
|
|
AND t.deleted_at IS NULL
|
|
ORDER BY t.planned_date ASC
|
|
", [$this->userId, $today, $endDate]);
|
|
|
|
foreach ($pendingTransactions as $row) {
|
|
$amount = abs($row->amount);
|
|
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
|
|
|
|
if ($row->type === 'credit') {
|
|
$totalIncomeConverted += $converted;
|
|
} else {
|
|
$totalExpenseConverted += $converted;
|
|
}
|
|
|
|
$result[] = [
|
|
'id' => $row->id,
|
|
'description' => $row->description,
|
|
'amount' => round($amount, 2),
|
|
'amount_converted' => round($converted, 2),
|
|
'currency' => $row->currency,
|
|
'type' => $row->type,
|
|
'source_type' => 'transaction',
|
|
'status' => $row->status,
|
|
'date' => $row->date,
|
|
'days_until' => max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1),
|
|
'account' => $row->account_name,
|
|
'category' => $row->category_name,
|
|
'category_icon' => $row->category_icon,
|
|
'cost_center' => $row->cost_center_name,
|
|
];
|
|
}
|
|
|
|
// 2. Cuotas de pasivos pendientes
|
|
$pendingInstallments = DB::select("
|
|
SELECT
|
|
li.id,
|
|
la.name as description,
|
|
li.installment_amount as amount,
|
|
'debit' as type,
|
|
li.due_date as date,
|
|
li.status,
|
|
la.currency,
|
|
a.name as account_name
|
|
FROM liability_installments li
|
|
JOIN liability_accounts la ON li.liability_account_id = la.id
|
|
LEFT JOIN accounts a ON la.account_id = a.id
|
|
WHERE la.user_id = ?
|
|
AND li.status = 'pending'
|
|
AND li.due_date >= ?
|
|
AND li.due_date <= ?
|
|
AND li.deleted_at IS NULL
|
|
ORDER BY li.due_date ASC
|
|
", [$this->userId, $today, $endDate]);
|
|
|
|
foreach ($pendingInstallments as $row) {
|
|
$amount = abs($row->amount);
|
|
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
|
|
$totalExpenseConverted += $converted;
|
|
|
|
$result[] = [
|
|
'id' => $row->id,
|
|
'description' => $row->description . ' (Cuota)',
|
|
'amount' => round($amount, 2),
|
|
'amount_converted' => round($converted, 2),
|
|
'currency' => $row->currency,
|
|
'type' => 'debit',
|
|
'source_type' => 'liability_installment',
|
|
'status' => $row->status,
|
|
'date' => $row->date,
|
|
'days_until' => max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1),
|
|
'account' => $row->account_name,
|
|
'category' => null,
|
|
'category_icon' => null,
|
|
'cost_center' => null,
|
|
];
|
|
}
|
|
|
|
// 3. Proyecciones de recurrencias activas
|
|
$recurrences = DB::select("
|
|
SELECT
|
|
rt.id,
|
|
rt.name,
|
|
rt.transaction_description as description,
|
|
rt.planned_amount as amount,
|
|
rt.type,
|
|
rt.frequency,
|
|
rt.day_of_month,
|
|
rt.start_date,
|
|
rt.end_date,
|
|
COALESCE(a.currency, 'EUR') as currency,
|
|
a.name as account_name,
|
|
c.name as category_name,
|
|
c.icon as category_icon,
|
|
cc.name as cost_center_name
|
|
FROM recurring_templates rt
|
|
LEFT JOIN accounts a ON rt.account_id = a.id
|
|
LEFT JOIN categories c ON rt.category_id = c.id
|
|
LEFT JOIN cost_centers cc ON rt.cost_center_id = cc.id
|
|
WHERE rt.user_id = ?
|
|
AND rt.is_active = 1
|
|
AND rt.deleted_at IS NULL
|
|
AND (rt.end_date IS NULL OR rt.end_date >= ?)
|
|
", [$this->userId, $today]);
|
|
|
|
foreach ($recurrences as $rec) {
|
|
// Calcular próximas ejecuciones dentro del período
|
|
$nextDates = $this->getNextRecurrenceDates($rec, $today, $endDate);
|
|
|
|
foreach ($nextDates as $nextDate) {
|
|
$amount = abs($rec->amount);
|
|
$converted = $this->convertToPrimaryCurrency($amount, $rec->currency);
|
|
|
|
if ($rec->type === 'credit') {
|
|
$totalIncomeConverted += $converted;
|
|
} else {
|
|
$totalExpenseConverted += $converted;
|
|
}
|
|
|
|
$result[] = [
|
|
'id' => $rec->id,
|
|
'description' => $rec->name . ' (Recurrente)',
|
|
'amount' => round($amount, 2),
|
|
'amount_converted' => round($converted, 2),
|
|
'currency' => $rec->currency,
|
|
'type' => $rec->type,
|
|
'source_type' => 'recurring',
|
|
'status' => 'projected',
|
|
'date' => $nextDate,
|
|
'days_until' => max(0, Carbon::parse($nextDate)->diffInDays(now(), false) * -1),
|
|
'account' => $rec->account_name,
|
|
'category' => $rec->category_name,
|
|
'category_icon' => $rec->category_icon,
|
|
'cost_center' => $rec->cost_center_name,
|
|
];
|
|
}
|
|
}
|
|
|
|
// Ordenar por fecha
|
|
usort($result, fn($a, $b) => strcmp($a['date'], $b['date']));
|
|
|
|
return response()->json([
|
|
'data' => $result,
|
|
'currency' => $this->primaryCurrency,
|
|
'summary' => [
|
|
'total_items' => count($result),
|
|
'total_income' => round($totalIncomeConverted, 2),
|
|
'total_expense' => round($totalExpenseConverted, 2),
|
|
'net_impact' => round($totalIncomeConverted - $totalExpenseConverted, 2),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Transacciones vencidas (pendientes de pago)
|
|
* Incluye: cuotas de pasivos vencidas y transacciones pendientes/scheduled pasadas
|
|
*/
|
|
public function overdueTransactions(Request $request)
|
|
{
|
|
$this->init();
|
|
|
|
$today = now()->format('Y-m-d');
|
|
$result = [];
|
|
$totalOverdueConverted = 0;
|
|
|
|
// 1. Cuotas de pasivos vencidas
|
|
$overdueInstallments = DB::select("
|
|
SELECT
|
|
li.id,
|
|
la.name as description,
|
|
li.installment_amount as amount,
|
|
li.due_date,
|
|
la.currency,
|
|
DATEDIFF(?, li.due_date) as days_overdue,
|
|
a.name as account_name
|
|
FROM liability_installments li
|
|
JOIN liability_accounts la ON li.liability_account_id = la.id
|
|
LEFT JOIN accounts a ON la.account_id = a.id
|
|
WHERE la.user_id = ?
|
|
AND li.status != 'paid'
|
|
AND li.due_date < ?
|
|
AND li.deleted_at IS NULL
|
|
ORDER BY li.due_date ASC
|
|
", [$today, $this->userId, $today]);
|
|
|
|
foreach ($overdueInstallments as $row) {
|
|
$converted = $this->convertToPrimaryCurrency($row->amount, $row->currency);
|
|
$totalOverdueConverted += $converted;
|
|
|
|
$result[] = [
|
|
'id' => $row->id,
|
|
'description' => $row->description . ' (Cuota)',
|
|
'amount' => round($row->amount, 2),
|
|
'amount_converted' => round($converted, 2),
|
|
'currency' => $row->currency,
|
|
'due_date' => $row->due_date,
|
|
'days_overdue' => (int) $row->days_overdue,
|
|
'source_type' => 'liability_installment',
|
|
'account' => $row->account_name,
|
|
'category' => null,
|
|
];
|
|
}
|
|
|
|
// 2. Transacciones pendientes/scheduled con fecha pasada
|
|
$overdueTransactions = DB::select("
|
|
SELECT
|
|
t.id,
|
|
t.description,
|
|
COALESCE(t.planned_amount, t.amount) as amount,
|
|
t.planned_date as due_date,
|
|
t.type,
|
|
t.status,
|
|
COALESCE(a.currency, 'EUR') as currency,
|
|
DATEDIFF(?, t.planned_date) as days_overdue,
|
|
a.name as account_name,
|
|
c.name as category_name
|
|
FROM transactions t
|
|
LEFT JOIN accounts a ON t.account_id = a.id
|
|
LEFT JOIN categories c ON t.category_id = c.id
|
|
WHERE t.user_id = ?
|
|
AND t.status IN ('pending', 'scheduled')
|
|
AND t.planned_date < ?
|
|
AND t.deleted_at IS NULL
|
|
ORDER BY t.planned_date ASC
|
|
", [$today, $this->userId, $today]);
|
|
|
|
foreach ($overdueTransactions as $row) {
|
|
$amount = abs($row->amount);
|
|
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
|
|
$totalOverdueConverted += $converted;
|
|
|
|
$result[] = [
|
|
'id' => $row->id,
|
|
'description' => $row->description,
|
|
'amount' => round($amount, 2),
|
|
'amount_converted' => round($converted, 2),
|
|
'currency' => $row->currency,
|
|
'due_date' => $row->due_date,
|
|
'days_overdue' => (int) $row->days_overdue,
|
|
'source_type' => 'transaction',
|
|
'type' => $row->type,
|
|
'status' => $row->status,
|
|
'account' => $row->account_name,
|
|
'category' => $row->category_name,
|
|
];
|
|
}
|
|
|
|
// Ordenar por días de atraso (más atrasado primero)
|
|
usort($result, fn($a, $b) => $b['days_overdue'] <=> $a['days_overdue']);
|
|
|
|
return response()->json([
|
|
'data' => $result,
|
|
'currency' => $this->primaryCurrency,
|
|
'summary' => [
|
|
'total_overdue' => count($result),
|
|
'total_amount' => round($totalOverdueConverted, 2),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Resumen ejecutivo completo
|
|
*/
|
|
public function executiveSummary(Request $request)
|
|
{
|
|
$this->init();
|
|
$startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d'));
|
|
$endDate = $request->get('end_date', now()->format('Y-m-d'));
|
|
|
|
// Totales del período
|
|
$totals = DB::select("
|
|
SELECT
|
|
COALESCE(a.currency, 'EUR') as currency,
|
|
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
|
|
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense,
|
|
COUNT(*) as transactions
|
|
FROM transactions t
|
|
LEFT JOIN accounts a ON t.account_id = a.id
|
|
WHERE t.user_id = ?
|
|
AND t.effective_date BETWEEN ? AND ?
|
|
AND t.deleted_at IS NULL
|
|
AND {$this->excludeTransfers()}
|
|
GROUP BY COALESCE(a.currency, 'EUR')
|
|
", [$this->userId, $startDate, $endDate]);
|
|
|
|
$totalIncome = 0;
|
|
$totalExpense = 0;
|
|
$totalTransactions = 0;
|
|
$byCurrency = [];
|
|
|
|
foreach ($totals as $row) {
|
|
$totalIncome += $this->convertToPrimaryCurrency($row->income, $row->currency);
|
|
$totalExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency);
|
|
$totalTransactions += $row->transactions;
|
|
$byCurrency[$row->currency] = [
|
|
'income' => round($row->income, 2),
|
|
'expense' => round($row->expense, 2),
|
|
];
|
|
}
|
|
|
|
// Top 5 categorías de gasto
|
|
$topCategories = DB::select("
|
|
SELECT
|
|
c.name,
|
|
c.icon,
|
|
c.color,
|
|
COALESCE(a.currency, 'EUR') as currency,
|
|
SUM(ABS(t.amount)) as total
|
|
FROM transactions t
|
|
LEFT JOIN categories c ON t.category_id = c.id
|
|
LEFT JOIN accounts a ON t.account_id = a.id
|
|
WHERE t.user_id = ?
|
|
AND t.type = 'debit'
|
|
AND t.effective_date BETWEEN ? AND ?
|
|
AND t.deleted_at IS NULL
|
|
AND {$this->excludeTransfers()}
|
|
GROUP BY c.id, c.name, c.icon, c.color, COALESCE(a.currency, 'EUR')
|
|
", [$this->userId, $startDate, $endDate]);
|
|
|
|
$categoryTotals = [];
|
|
foreach ($topCategories as $row) {
|
|
$name = $row->name ?? 'Sin categoría';
|
|
if (!isset($categoryTotals[$name])) {
|
|
$categoryTotals[$name] = [
|
|
'name' => $name,
|
|
'icon' => $row->icon,
|
|
'color' => $row->color,
|
|
'total' => 0,
|
|
];
|
|
}
|
|
$categoryTotals[$name]['total'] += $this->convertToPrimaryCurrency($row->total, $row->currency);
|
|
}
|
|
usort($categoryTotals, fn($a, $b) => $b['total'] <=> $a['total']);
|
|
$top5Categories = array_slice($categoryTotals, 0, 5);
|
|
|
|
// Cuentas
|
|
$accounts = Account::where('user_id', $this->userId)
|
|
->where('include_in_total', true)
|
|
->get();
|
|
|
|
$totalBalance = 0;
|
|
foreach ($accounts as $acc) {
|
|
$totalBalance += $this->convertToPrimaryCurrency($acc->current_balance, $acc->currency);
|
|
}
|
|
|
|
return response()->json([
|
|
'period' => ['start' => $startDate, 'end' => $endDate],
|
|
'currency' => $this->primaryCurrency,
|
|
'totals' => [
|
|
'income' => round($totalIncome, 2),
|
|
'expense' => round($totalExpense, 2),
|
|
'balance' => round($totalIncome - $totalExpense, 2),
|
|
'savings_rate' => $totalIncome > 0 ? round((($totalIncome - $totalExpense) / $totalIncome) * 100, 1) : 0,
|
|
'transactions' => $totalTransactions,
|
|
'by_currency' => $byCurrency,
|
|
],
|
|
'accounts' => [
|
|
'count' => $accounts->count(),
|
|
'total_balance' => round($totalBalance, 2),
|
|
],
|
|
'top_expense_categories' => array_map(function($c) {
|
|
return [
|
|
'name' => $c['name'],
|
|
'icon' => $c['icon'],
|
|
'color' => $c['color'],
|
|
'total' => round($c['total'], 2),
|
|
];
|
|
}, $top5Categories),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Calcula las próximas fechas de ejecución de una recurrencia dentro de un período
|
|
*/
|
|
private function getNextRecurrenceDates($recurrence, $startDate, $endDate)
|
|
{
|
|
$dates = [];
|
|
$start = Carbon::parse($startDate);
|
|
$end = Carbon::parse($endDate);
|
|
$recStart = Carbon::parse($recurrence->start_date);
|
|
$recEnd = $recurrence->end_date ? Carbon::parse($recurrence->end_date) : null;
|
|
|
|
// Si la recurrencia termina antes del período, no hay fechas
|
|
if ($recEnd && $recEnd->lt($start)) {
|
|
return $dates;
|
|
}
|
|
|
|
// Calcular la primera fecha dentro del período
|
|
$current = $recStart->copy();
|
|
|
|
// Avanzar hasta estar dentro del período
|
|
while ($current->lt($start)) {
|
|
$current = $this->advanceToNextOccurrence($current, $recurrence);
|
|
if ($current->gt($end)) {
|
|
return $dates;
|
|
}
|
|
}
|
|
|
|
// Generar fechas hasta el fin del período
|
|
$maxIterations = 100; // Prevenir bucles infinitos
|
|
$iterations = 0;
|
|
|
|
while ($current->lte($end) && $iterations < $maxIterations) {
|
|
// Verificar que no pase de la fecha de fin de la recurrencia
|
|
if ($recEnd && $current->gt($recEnd)) {
|
|
break;
|
|
}
|
|
|
|
$dates[] = $current->format('Y-m-d');
|
|
$current = $this->advanceToNextOccurrence($current, $recurrence);
|
|
$iterations++;
|
|
}
|
|
|
|
return $dates;
|
|
}
|
|
|
|
/**
|
|
* Avanza a la próxima ocurrencia según la frecuencia
|
|
*/
|
|
private function advanceToNextOccurrence($date, $recurrence)
|
|
{
|
|
$next = $date->copy();
|
|
|
|
switch ($recurrence->frequency) {
|
|
case 'daily':
|
|
$next->addDays($recurrence->frequency_interval ?? 1);
|
|
break;
|
|
case 'weekly':
|
|
$next->addWeeks($recurrence->frequency_interval ?? 1);
|
|
break;
|
|
case 'biweekly':
|
|
$next->addWeeks(2);
|
|
break;
|
|
case 'monthly':
|
|
$next->addMonths($recurrence->frequency_interval ?? 1);
|
|
if ($recurrence->day_of_month) {
|
|
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
|
|
}
|
|
break;
|
|
case 'bimonthly':
|
|
$next->addMonths(2);
|
|
if ($recurrence->day_of_month) {
|
|
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
|
|
}
|
|
break;
|
|
case 'quarterly':
|
|
$next->addMonths(3);
|
|
if ($recurrence->day_of_month) {
|
|
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
|
|
}
|
|
break;
|
|
case 'semiannual':
|
|
$next->addMonths(6);
|
|
if ($recurrence->day_of_month) {
|
|
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
|
|
}
|
|
break;
|
|
case 'annual':
|
|
$next->addYears($recurrence->frequency_interval ?? 1);
|
|
break;
|
|
default:
|
|
$next->addMonths(1);
|
|
}
|
|
|
|
return $next;
|
|
}
|
|
}
|