1128 lines
45 KiB
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),
|
|
]);
|
|
}
|
|
}
|