1915 lines
78 KiB
PHP
1915 lines
78 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');
|
|
$today = now()->format('Y-m-d');
|
|
$endOfMonth = now()->endOfMonth()->format('Y-m-d');
|
|
|
|
// =========================================================================
|
|
// 1. HISTÓRICO: Média mensal dos últimos N meses
|
|
// =========================================================================
|
|
$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);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 2. MÊS ATUAL: Transações já realizadas (effective_date)
|
|
// =========================================================================
|
|
$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;
|
|
|
|
// =========================================================================
|
|
// 3. RECORRÊNCIAS PENDENTES: Até o fim do mês
|
|
// =========================================================================
|
|
$recurringIncome = 0;
|
|
$recurringExpense = 0;
|
|
|
|
$recurrences = DB::select("
|
|
SELECT
|
|
rt.id,
|
|
rt.name,
|
|
rt.planned_amount,
|
|
rt.type,
|
|
rt.frequency,
|
|
rt.day_of_month,
|
|
rt.start_date,
|
|
rt.end_date,
|
|
rt.last_generated_date,
|
|
COALESCE(a.currency, 'EUR') as currency
|
|
FROM recurring_templates rt
|
|
LEFT JOIN accounts a ON rt.account_id = a.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) {
|
|
// Verificar se ainda vai executar este mês
|
|
$nextDates = $this->getNextRecurrenceDates($rec, $today, $endOfMonth);
|
|
|
|
foreach ($nextDates as $nextDate) {
|
|
$amount = abs($rec->planned_amount);
|
|
$converted = $this->convertToPrimaryCurrency($amount, $rec->currency);
|
|
|
|
if ($rec->type === 'credit') {
|
|
$recurringIncome += $converted;
|
|
} else {
|
|
$recurringExpense += $converted;
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 4. PASSIVOS PENDENTES: Parcelas até o fim do mês
|
|
// =========================================================================
|
|
$liabilityExpense = 0;
|
|
|
|
$pendingInstallments = DB::select("
|
|
SELECT
|
|
li.installment_amount as amount,
|
|
la.currency
|
|
FROM liability_installments li
|
|
JOIN liability_accounts la ON li.liability_account_id = la.id
|
|
WHERE la.user_id = ?
|
|
AND li.status = 'pending'
|
|
AND li.due_date >= ?
|
|
AND li.due_date <= ?
|
|
AND li.deleted_at IS NULL
|
|
", [$this->userId, $today, $endOfMonth]);
|
|
|
|
foreach ($pendingInstallments as $row) {
|
|
$liabilityExpense += $this->convertToPrimaryCurrency(abs($row->amount), $row->currency);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 5. TRANSAÇÕES EM ATRASO (overdue)
|
|
// =========================================================================
|
|
$overdueExpense = 0;
|
|
|
|
// Parcelas de passivos vencidas
|
|
$overdueInstallments = DB::select("
|
|
SELECT li.installment_amount as amount, la.currency
|
|
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
|
|
", [$this->userId, $today]);
|
|
|
|
foreach ($overdueInstallments as $row) {
|
|
$overdueExpense += $this->convertToPrimaryCurrency(abs($row->amount), $row->currency);
|
|
}
|
|
|
|
// Recorrências que deveriam ter executado mas não executaram
|
|
$overdueRecurrences = $this->getOverdueRecurrences($today);
|
|
foreach ($overdueRecurrences as $rec) {
|
|
$overdueExpense += $this->convertToPrimaryCurrency($rec['amount'], $rec['currency']);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 6. CÁLCULOS FINAIS DA PROJEÇÃO
|
|
// =========================================================================
|
|
|
|
// Projeção simples (extrapolação linear)
|
|
$simpleProjectedExpense = ($currExpense / $daysElapsed) * $daysInMonth;
|
|
$simpleProjectedIncome = ($currIncome / $daysElapsed) * $daysInMonth;
|
|
|
|
// Projeção inteligente: realizado + pendente (recorrências + passivos)
|
|
$smartProjectedIncome = $currIncome + $recurringIncome;
|
|
$smartProjectedExpense = $currExpense + $recurringExpense + $liabilityExpense;
|
|
|
|
return response()->json([
|
|
'historical_average' => [
|
|
'income' => round($histIncome, 2),
|
|
'expense' => round($histExpense, 2),
|
|
'balance' => round($histIncome - $histExpense, 2),
|
|
],
|
|
'current_month' => [
|
|
'income' => round($currIncome, 2),
|
|
'expense' => round($currExpense, 2),
|
|
'balance' => round($currIncome - $currExpense, 2),
|
|
'days_elapsed' => $daysElapsed,
|
|
'days_remaining' => $daysRemaining,
|
|
],
|
|
'pending_this_month' => [
|
|
'recurring_income' => round($recurringIncome, 2),
|
|
'recurring_expense' => round($recurringExpense, 2),
|
|
'liability_installments' => round($liabilityExpense, 2),
|
|
'total_pending_expense' => round($recurringExpense + $liabilityExpense, 2),
|
|
],
|
|
'overdue' => [
|
|
'total' => round($overdueExpense, 2),
|
|
],
|
|
'projection' => [
|
|
// Valores principais (usa projeção inteligente)
|
|
'income' => round($smartProjectedIncome, 2),
|
|
'expense' => round($smartProjectedExpense, 2),
|
|
'balance' => round($smartProjectedIncome - $smartProjectedExpense, 2),
|
|
// Projeção simples (extrapolação linear)
|
|
'simple' => [
|
|
'income' => round($simpleProjectedIncome, 2),
|
|
'expense' => round($simpleProjectedExpense, 2),
|
|
'balance' => round($simpleProjectedIncome - $simpleProjectedExpense, 2),
|
|
],
|
|
// Projeção inteligente (realizado + recorrências + passivos)
|
|
'smart' => [
|
|
'income' => round($smartProjectedIncome, 2),
|
|
'expense' => round($smartProjectedExpense, 2),
|
|
'balance' => round($smartProjectedIncome - $smartProjectedExpense, 2),
|
|
],
|
|
],
|
|
'vs_average' => [
|
|
'income' => $histIncome > 0 ? round((($smartProjectedIncome - $histIncome) / $histIncome) * 100, 1) : 0,
|
|
'expense' => $histExpense > 0 ? round((($smartProjectedExpense - $histExpense) / $histExpense) * 100, 1) : 0,
|
|
],
|
|
'currency' => $this->primaryCurrency,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Projeção de saldo para gráfico com dados diários/semanais
|
|
*/
|
|
public function projectionChart(Request $request)
|
|
{
|
|
$this->init();
|
|
|
|
$months = (int) min(max($request->input('months', 3), 1), 12);
|
|
$today = Carbon::today();
|
|
$endDate = $today->copy()->addMonths($months);
|
|
|
|
// Obter saldo atual total das contas (simplificado - assumindo mesma moeda)
|
|
$currentBalance = DB::selectOne("
|
|
SELECT COALESCE(SUM(current_balance), 0) as total
|
|
FROM accounts
|
|
WHERE user_id = ?
|
|
AND include_in_total = 1
|
|
AND deleted_at IS NULL
|
|
", [$this->userId])->total ?? 0;
|
|
|
|
// Gerar pontos de dados (diário para até 3 meses, semanal para mais)
|
|
$dataPoints = [];
|
|
$runningBalance = (float) $currentBalance;
|
|
$interval = $months <= 3 ? 'day' : 'week';
|
|
$current = $today->copy();
|
|
|
|
// Buscar recorrências ativas
|
|
$recurrences = DB::select("
|
|
SELECT
|
|
rt.id,
|
|
rt.name,
|
|
rt.planned_amount,
|
|
rt.type,
|
|
rt.frequency,
|
|
rt.day_of_month,
|
|
rt.start_date,
|
|
rt.end_date,
|
|
COALESCE(a.currency, 'EUR') as currency
|
|
FROM recurring_templates rt
|
|
LEFT JOIN accounts a ON rt.account_id = a.id
|
|
WHERE rt.user_id = ?
|
|
AND rt.is_active = 1
|
|
AND rt.deleted_at IS NULL
|
|
", [$this->userId]);
|
|
|
|
// Buscar parcelas de passivos pendentes
|
|
$liabilityInstallments = DB::select("
|
|
SELECT
|
|
li.due_date,
|
|
li.installment_amount as amount,
|
|
la.currency
|
|
FROM liability_installments li
|
|
JOIN liability_accounts la ON li.liability_account_id = la.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
|
|
", [$this->userId, $today->toDateString(), $endDate->toDateString()]);
|
|
|
|
// Buscar transações agendadas/pendentes (incluindo atrasadas)
|
|
$scheduledTransactions = DB::select("
|
|
SELECT
|
|
t.effective_date as date,
|
|
t.amount,
|
|
t.type,
|
|
COALESCE(a.currency, 'EUR') as currency,
|
|
CASE WHEN t.effective_date < ? THEN 1 ELSE 0 END as is_overdue
|
|
FROM transactions t
|
|
LEFT JOIN accounts a ON t.account_id = a.id
|
|
WHERE t.user_id = ?
|
|
AND t.status IN ('pending', 'scheduled')
|
|
AND t.effective_date <= ?
|
|
AND t.deleted_at IS NULL
|
|
ORDER BY t.effective_date
|
|
", [$today->toDateString(), $this->userId, $endDate->toDateString()]);
|
|
|
|
// Separar transações atrasadas para processar primeiro
|
|
$overdueTransactions = array_filter($scheduledTransactions, fn($tx) => $tx->is_overdue);
|
|
$futureTransactions = array_filter($scheduledTransactions, fn($tx) => !$tx->is_overdue);
|
|
|
|
// Processar transações atrasadas ANTES do ponto inicial
|
|
foreach ($overdueTransactions as $tx) {
|
|
$amount = $this->convertToPrimaryCurrency(abs($tx->amount), $tx->currency);
|
|
if ($tx->type === 'credit') {
|
|
$currentBalance += $amount;
|
|
} else {
|
|
$currentBalance -= $amount;
|
|
}
|
|
$runningBalance = $currentBalance;
|
|
}
|
|
|
|
// Ponto inicial (já inclui o impacto das transações atrasadas)
|
|
$dataPoints[] = [
|
|
'date' => $today->toDateString(),
|
|
'balance' => round($runningBalance, 2),
|
|
'label' => $today->format('d/m'),
|
|
'isToday' => true,
|
|
'has_overdue' => count($overdueTransactions) > 0,
|
|
];
|
|
|
|
// Gerar pontos até a data final
|
|
while ($current->lt($endDate)) {
|
|
if ($interval === 'day') {
|
|
$current->addDay();
|
|
} else {
|
|
$current->addWeek();
|
|
}
|
|
|
|
if ($current->gt($endDate)) break;
|
|
|
|
$periodStart = $dataPoints[count($dataPoints) - 1]['date'];
|
|
$periodEnd = $current->toDateString();
|
|
|
|
// Somar recorrências neste período
|
|
foreach ($recurrences as $rec) {
|
|
$dates = $this->getNextRecurrenceDates($rec, $periodStart, $periodEnd);
|
|
foreach ($dates as $date) {
|
|
$amount = $this->convertToPrimaryCurrency(abs($rec->planned_amount), $rec->currency);
|
|
if ($rec->type === 'credit') {
|
|
$runningBalance += $amount;
|
|
} else {
|
|
$runningBalance -= $amount;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Somar parcelas de passivos neste período
|
|
foreach ($liabilityInstallments as $inst) {
|
|
if ($inst->due_date > $periodStart && $inst->due_date <= $periodEnd) {
|
|
$amount = $this->convertToPrimaryCurrency(abs($inst->amount), $inst->currency);
|
|
$runningBalance -= $amount;
|
|
}
|
|
}
|
|
|
|
// Somar transações agendadas neste período (apenas futuras)
|
|
foreach ($futureTransactions as $tx) {
|
|
if ($tx->date > $periodStart && $tx->date <= $periodEnd) {
|
|
$amount = $this->convertToPrimaryCurrency(abs($tx->amount), $tx->currency);
|
|
if ($tx->type === 'credit') {
|
|
$runningBalance += $amount;
|
|
} else {
|
|
$runningBalance -= $amount;
|
|
}
|
|
}
|
|
}
|
|
|
|
$dataPoints[] = [
|
|
'date' => $current->toDateString(),
|
|
'balance' => round($runningBalance, 2),
|
|
'label' => $current->format('d/m'),
|
|
'isToday' => false,
|
|
];
|
|
}
|
|
|
|
// Calcular estatísticas
|
|
$balances = array_column($dataPoints, 'balance');
|
|
$minBalance = min($balances);
|
|
$maxBalance = max($balances);
|
|
$avgBalance = array_sum($balances) / count($balances);
|
|
$finalBalance = end($balances);
|
|
|
|
// Detectar mês de saldo negativo (se houver)
|
|
$negativeMonth = null;
|
|
foreach ($dataPoints as $point) {
|
|
if ($point['balance'] < 0) {
|
|
$negativeMonth = Carbon::parse($point['date'])->format('M Y');
|
|
break;
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'data' => $dataPoints,
|
|
'summary' => [
|
|
'current_balance' => round($currentBalance, 2),
|
|
'final_balance' => round($finalBalance, 2),
|
|
'min_balance' => round($minBalance, 2),
|
|
'max_balance' => round($maxBalance, 2),
|
|
'avg_balance' => round($avgBalance, 2),
|
|
'change' => round($finalBalance - $currentBalance, 2),
|
|
'change_percent' => $currentBalance != 0 ? round((($finalBalance - $currentBalance) / abs($currentBalance)) * 100, 1) : 0,
|
|
'negative_month' => $negativeMonth,
|
|
'overdue_count' => count($overdueTransactions),
|
|
'overdue_impact' => round(array_reduce($overdueTransactions, function($carry, $tx) {
|
|
$amount = $this->convertToPrimaryCurrency(abs($tx->amount), $tx->currency);
|
|
return $carry + ($tx->type === 'credit' ? $amount : -$amount);
|
|
}, 0), 2),
|
|
],
|
|
'period' => [
|
|
'start' => $today->toDateString(),
|
|
'end' => $endDate->toDateString(),
|
|
'months' => $months,
|
|
'interval' => $interval,
|
|
'total_points' => count($dataPoints),
|
|
],
|
|
'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';
|
|
// CORRIGIDO: usar planned_amount em vez de amount
|
|
$amount = abs($t->planned_amount ?? 0);
|
|
|
|
// Converter para valor mensal baseado na frequência
|
|
$monthlyAmount = $this->convertToMonthlyAmount($amount, $t->frequency);
|
|
$converted = $this->convertToPrimaryCurrency($monthlyAmount, $currency);
|
|
|
|
if (!isset($byCurrency[$currency])) {
|
|
$byCurrency[$currency] = ['income' => 0, 'expense' => 0];
|
|
}
|
|
|
|
if ($t->type === 'credit') {
|
|
$monthlyIncomeConverted += $converted;
|
|
$byCurrency[$currency]['income'] += $monthlyAmount;
|
|
} else {
|
|
$monthlyExpenseConverted += $converted;
|
|
$byCurrency[$currency]['expense'] += $monthlyAmount;
|
|
}
|
|
|
|
return [
|
|
'id' => $t->id,
|
|
// CORRIGIDO: usar name em vez de description
|
|
'description' => $t->name ?? $t->transaction_description,
|
|
'amount' => round($amount, 2),
|
|
'monthly_amount' => round($monthlyAmount, 2),
|
|
'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,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Converte um valor para equivalente mensal baseado na frequência
|
|
*/
|
|
private function convertToMonthlyAmount(float $amount, string $frequency): float
|
|
{
|
|
return match($frequency) {
|
|
'daily' => $amount * 30,
|
|
'weekly' => $amount * 4.33,
|
|
'biweekly' => $amount * 2.17,
|
|
'monthly' => $amount,
|
|
'bimonthly' => $amount / 2,
|
|
'quarterly' => $amount / 3,
|
|
'semiannual' => $amount / 6,
|
|
'annual' => $amount / 12,
|
|
default => $amount,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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_contract_value ?? 0;
|
|
// Usar installment_amount en lugar de amount
|
|
$paidAmount = $l->installments->where('status', 'paid')->sum('installment_amount');
|
|
$pendingAmount = $l->installments->where('status', '!=', 'paid')->sum('installment_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->installment_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' => (int) 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' => (int) 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' => (int) 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_transactions' => 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, transacciones pendientes/scheduled pasadas,
|
|
* y recurrencias que deberían haber ejecutado pero no lo hicieron
|
|
*/
|
|
public function overdueTransactions(Request $request)
|
|
{
|
|
\Log::info('overdueTransactions called');
|
|
try {
|
|
$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,
|
|
];
|
|
}
|
|
|
|
// 3. Recurrencias activas que deberían haber ejecutado pero no lo hicieron
|
|
$overdueRecurrences = $this->getOverdueRecurrences($today);
|
|
foreach ($overdueRecurrences as $rec) {
|
|
$converted = $this->convertToPrimaryCurrency($rec['amount'], $rec['currency']);
|
|
$totalOverdueConverted += $converted;
|
|
$rec['amount_converted'] = round($converted, 2);
|
|
$result[] = $rec;
|
|
}
|
|
|
|
// 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),
|
|
],
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
\Log::error('overdueTransactions error: ' . $e->getMessage() . ' at line ' . $e->getLine());
|
|
return response()->json([
|
|
'error' => $e->getMessage(),
|
|
'line' => $e->getLine(),
|
|
'file' => $e->getFile()
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 (EXCLUYE startDate para evitar duplicados)
|
|
while ($current->lte($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;
|
|
}
|
|
|
|
/**
|
|
* Obtiene las recurrencias que deberían haber ejecutado pero no lo hicieron
|
|
* Busca la última ejecución esperada y verifica si existe una transacción para esa fecha
|
|
*/
|
|
private function getOverdueRecurrences($today)
|
|
{
|
|
$result = [];
|
|
$todayCarbon = Carbon::parse($today);
|
|
|
|
// Obtener todas las 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,
|
|
rt.last_generated_date,
|
|
COALESCE(a.currency, 'EUR') as currency,
|
|
a.name as account_name,
|
|
c.name as category_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
|
|
WHERE rt.user_id = ?
|
|
AND rt.is_active = 1
|
|
AND rt.deleted_at IS NULL
|
|
", [$this->userId]);
|
|
|
|
foreach ($recurrences as $rec) {
|
|
// Calcular la fecha de la última ejecución esperada
|
|
$expectedDate = $this->getLastExpectedExecutionDate($rec, $todayCarbon);
|
|
|
|
if (!$expectedDate) {
|
|
continue;
|
|
}
|
|
|
|
// Verificar si ya existe una transacción para esta recurrencia en esa fecha
|
|
// Buscamos por descripción similar y fecha cercana (±2 días)
|
|
$existingTransaction = DB::selectOne("
|
|
SELECT id FROM transactions
|
|
WHERE user_id = ?
|
|
AND (description LIKE ? OR description LIKE ?)
|
|
AND effective_date BETWEEN DATE_SUB(?, INTERVAL 2 DAY) AND DATE_ADD(?, INTERVAL 2 DAY)
|
|
AND deleted_at IS NULL
|
|
LIMIT 1
|
|
", [
|
|
$this->userId,
|
|
'%' . $rec->name . '%',
|
|
'%' . ($rec->description ?? '') . '%',
|
|
$expectedDate->format('Y-m-d'),
|
|
$expectedDate->format('Y-m-d')
|
|
]);
|
|
|
|
// Si no existe transacción y la fecha esperada es anterior a hoy, está vencida
|
|
if (!$existingTransaction && $expectedDate->lt($todayCarbon)) {
|
|
$daysOverdue = abs($expectedDate->diffInDays($todayCarbon));
|
|
|
|
$result[] = [
|
|
'id' => $rec->id,
|
|
'description' => $rec->name,
|
|
'amount' => round(abs($rec->amount), 2),
|
|
'currency' => $rec->currency,
|
|
'due_date' => $expectedDate->format('Y-m-d'),
|
|
'days_overdue' => (int) $daysOverdue,
|
|
'source_type' => 'recurring_overdue',
|
|
'type' => $rec->type,
|
|
'status' => 'not_executed',
|
|
'account' => $rec->account_name,
|
|
'category' => $rec->category_name,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Calcula la fecha de la última ejecución esperada para una recurrencia
|
|
*/
|
|
private function getLastExpectedExecutionDate($recurrence, $today)
|
|
{
|
|
$startDate = Carbon::parse($recurrence->start_date);
|
|
|
|
// Si aún no ha llegado la fecha de inicio, no hay ejecución esperada
|
|
if ($startDate->gt($today)) {
|
|
return null;
|
|
}
|
|
|
|
// Si tiene fecha de fin y ya pasó, usar la fecha de fin
|
|
$endDate = $recurrence->end_date ? Carbon::parse($recurrence->end_date) : null;
|
|
$referenceDate = ($endDate && $endDate->lt($today)) ? $endDate : $today;
|
|
|
|
// Calcular la fecha esperada según la frecuencia
|
|
switch ($recurrence->frequency) {
|
|
case 'monthly':
|
|
$dayOfMonth = $recurrence->day_of_month ?? $startDate->day;
|
|
$expectedDate = $referenceDate->copy()->day(min($dayOfMonth, $referenceDate->daysInMonth));
|
|
|
|
// Si la fecha calculada es posterior a hoy, retroceder un mes
|
|
if ($expectedDate->gt($today)) {
|
|
$expectedDate->subMonth();
|
|
$expectedDate->day = min($dayOfMonth, $expectedDate->daysInMonth);
|
|
}
|
|
return $expectedDate;
|
|
|
|
case 'weekly':
|
|
$dayOfWeek = $startDate->dayOfWeek;
|
|
$expectedDate = $referenceDate->copy()->startOfWeek()->addDays($dayOfWeek);
|
|
if ($expectedDate->gt($today)) {
|
|
$expectedDate->subWeek();
|
|
}
|
|
return $expectedDate;
|
|
|
|
case 'biweekly':
|
|
$dayOfWeek = $startDate->dayOfWeek;
|
|
$weeksSinceStart = $startDate->diffInWeeks($referenceDate);
|
|
$biweeklyPeriods = floor($weeksSinceStart / 2);
|
|
$expectedDate = $startDate->copy()->addWeeks($biweeklyPeriods * 2);
|
|
if ($expectedDate->gt($today)) {
|
|
$expectedDate->subWeeks(2);
|
|
}
|
|
return $expectedDate;
|
|
|
|
case 'quarterly':
|
|
$dayOfMonth = $recurrence->day_of_month ?? $startDate->day;
|
|
$quarterMonth = floor(($referenceDate->month - 1) / 3) * 3 + 1;
|
|
$expectedDate = $referenceDate->copy()->month($quarterMonth)->day(min($dayOfMonth, Carbon::create($referenceDate->year, $quarterMonth, 1)->daysInMonth));
|
|
if ($expectedDate->gt($today)) {
|
|
$expectedDate->subMonths(3);
|
|
}
|
|
return $expectedDate;
|
|
|
|
case 'annual':
|
|
$expectedDate = Carbon::create($referenceDate->year, $startDate->month, min($startDate->day, Carbon::create($referenceDate->year, $startDate->month, 1)->daysInMonth));
|
|
if ($expectedDate->gt($today)) {
|
|
$expectedDate->subYear();
|
|
}
|
|
return $expectedDate;
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
}
|