fix(reports): corrigir future-transactions e overdue - v1.32.2

- Reescrito futureTransactions() com 3 fontes de dados:
  * Transações pending/scheduled (usando planned_date)
  * Cuotas de passivos pendentes
  * Projeções de recorrências ativas
- Adicionados helpers getNextRecurrenceDates() e advanceToNextOccurrence()
- Corrigida query SQL: removida referência c.name inexistente
- overdueTransactions() inclui cuotas e transações vencidas
- Deploy via script oficial deploy.sh

Closes: endpoints /api/reports/future-transactions e /api/reports/overdue
This commit is contained in:
marcoitaloesp-ai 2025-12-14 19:20:06 +00:00 committed by GitHub
parent 99be24e309
commit 1feb3354ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 315 additions and 29 deletions

View File

@ -5,6 +5,28 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.32.2] - 2025-12-14
### Fixed
- **Endpoint `/api/reports/future-transactions`** - Corregido error 500 y datos vacíos
- Reescrito completamente para incluir 3 fuentes de datos:
1. Transacciones pendientes/programadas (usando `planned_date` en lugar de `effective_date`)
2. Cuotas de pasivos pendientes (`liability_installments`)
3. Proyecciones de recurrencias activas (`recurring_templates`)
- Añadidos helpers `getNextRecurrenceDates()` y `advanceToNextOccurrence()` para calcular fechas futuras
- Corregida query SQL: removida referencia a columna `c.name` inexistente en tabla de cuotas
- Soporta frecuencias: diaria, semanal, quincenal, mensual, bimestral, trimestral, semestral, anual
- **Endpoint `/api/reports/overdue`** - Corregido para mostrar todos los vencidos
- Incluye cuotas de pasivos vencidas (due_date < hoy)
- Incluye transacciones pending/scheduled con fecha pasada
- Calcula días de atraso correctamente
- **Deploy** - Usado script oficial `deploy.sh` con rsync
- Evita problemas de caché y archivos en ubicaciones incorrectas
- Sincronización completa de directorios
## [1.32.1] - 2025-12-14
### Fixed

View File

@ -1 +1 @@
1.32.1
1.32.2

View File

@ -887,22 +887,29 @@ public function liabilities(Request $request)
/**
* Transacciones futuras programadas
* Incluye: transacciones pending/scheduled, cuotas de pasivos, y proyecciones de recurrencias
*/
public function futureTransactions(Request $request)
{
$this->init();
$days = $request->get('days', 30);
$days = (int) $request->get('days', 30);
$endDate = now()->addDays($days)->format('Y-m-d');
$today = now()->format('Y-m-d');
$data = DB::select("
$result = [];
$totalIncomeConverted = 0;
$totalExpenseConverted = 0;
// 1. Transacciones pendientes/scheduled (usando planned_date)
$pendingTransactions = DB::select("
SELECT
t.id,
t.description,
t.amount,
COALESCE(t.planned_amount, t.amount) as amount,
t.type,
t.effective_date,
t.planned_date as date,
t.status,
COALESCE(a.currency, 'EUR') as currency,
a.name as account_name,
c.name as category_name,
@ -913,16 +920,14 @@ public function futureTransactions(Request $request)
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.status IN ('pending', 'scheduled')
AND t.planned_date >= ?
AND t.planned_date <= ?
AND t.deleted_at IS NULL
ORDER BY t.effective_date ASC
ORDER BY t.planned_date ASC
", [$this->userId, $today, $endDate]);
$totalIncomeConverted = 0;
$totalExpenseConverted = 0;
$result = array_map(function($row) use (&$totalIncomeConverted, &$totalExpenseConverted) {
foreach ($pendingTransactions as $row) {
$amount = abs($row->amount);
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
@ -932,27 +937,137 @@ public function futureTransactions(Request $request)
$totalExpenseConverted += $converted;
}
return [
$result[] = [
'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()),
'source_type' => 'transaction',
'status' => $row->status,
'date' => $row->date,
'days_until' => max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1),
'account' => $row->account_name,
'category' => $row->category_name,
'category_icon' => $row->category_icon,
'cost_center' => $row->cost_center_name,
];
}, $data);
}
// 2. Cuotas de pasivos pendientes
$pendingInstallments = DB::select("
SELECT
li.id,
la.name as description,
li.installment_amount as amount,
'debit' as type,
li.due_date as date,
li.status,
la.currency,
a.name as account_name
FROM liability_installments li
JOIN liability_accounts la ON li.liability_account_id = la.id
LEFT JOIN accounts a ON la.account_id = a.id
WHERE la.user_id = ?
AND li.status = 'pending'
AND li.due_date >= ?
AND li.due_date <= ?
AND li.deleted_at IS NULL
ORDER BY li.due_date ASC
", [$this->userId, $today, $endDate]);
foreach ($pendingInstallments as $row) {
$amount = abs($row->amount);
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
$totalExpenseConverted += $converted;
$result[] = [
'id' => $row->id,
'description' => $row->description . ' (Cuota)',
'amount' => round($amount, 2),
'amount_converted' => round($converted, 2),
'currency' => $row->currency,
'type' => 'debit',
'source_type' => 'liability_installment',
'status' => $row->status,
'date' => $row->date,
'days_until' => max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1),
'account' => $row->account_name,
'category' => null,
'category_icon' => null,
'cost_center' => null,
];
}
// 3. Proyecciones de recurrencias activas
$recurrences = DB::select("
SELECT
rt.id,
rt.name,
rt.transaction_description as description,
rt.planned_amount as amount,
rt.type,
rt.frequency,
rt.day_of_month,
rt.start_date,
rt.end_date,
COALESCE(a.currency, 'EUR') as currency,
a.name as account_name,
c.name as category_name,
c.icon as category_icon,
cc.name as cost_center_name
FROM recurring_templates rt
LEFT JOIN accounts a ON rt.account_id = a.id
LEFT JOIN categories c ON rt.category_id = c.id
LEFT JOIN cost_centers cc ON rt.cost_center_id = cc.id
WHERE rt.user_id = ?
AND rt.is_active = 1
AND rt.deleted_at IS NULL
AND (rt.end_date IS NULL OR rt.end_date >= ?)
", [$this->userId, $today]);
foreach ($recurrences as $rec) {
// Calcular próximas ejecuciones dentro del período
$nextDates = $this->getNextRecurrenceDates($rec, $today, $endDate);
foreach ($nextDates as $nextDate) {
$amount = abs($rec->amount);
$converted = $this->convertToPrimaryCurrency($amount, $rec->currency);
if ($rec->type === 'credit') {
$totalIncomeConverted += $converted;
} else {
$totalExpenseConverted += $converted;
}
$result[] = [
'id' => $rec->id,
'description' => $rec->name . ' (Recurrente)',
'amount' => round($amount, 2),
'amount_converted' => round($converted, 2),
'currency' => $rec->currency,
'type' => $rec->type,
'source_type' => 'recurring',
'status' => 'projected',
'date' => $nextDate,
'days_until' => max(0, Carbon::parse($nextDate)->diffInDays(now(), false) * -1),
'account' => $rec->account_name,
'category' => $rec->category_name,
'category_icon' => $rec->category_icon,
'cost_center' => $rec->cost_center_name,
];
}
}
// Ordenar por fecha
usort($result, fn($a, $b) => strcmp($a['date'], $b['date']));
return response()->json([
'data' => $result,
'currency' => $this->primaryCurrency,
'summary' => [
'total_transactions' => count($result),
'total_items' => count($result),
'total_income' => round($totalIncomeConverted, 2),
'total_expense' => round($totalExpenseConverted, 2),
'net_impact' => round($totalIncomeConverted - $totalExpenseConverted, 2),
@ -962,24 +1077,29 @@ public function futureTransactions(Request $request)
/**
* Transacciones vencidas (pendientes de pago)
* Incluye: cuotas de pasivos vencidas y transacciones pendientes/scheduled pasadas
*/
public function overdueTransactions(Request $request)
{
$this->init();
$today = now()->format('Y-m-d');
$result = [];
$totalOverdueConverted = 0;
// Cuotas de pasivos vencidas
// 1. Cuotas de pasivos vencidas
$overdueInstallments = DB::select("
SELECT
li.id,
la.name as liability_name,
la.name as description,
li.installment_amount as amount,
li.due_date,
la.currency,
DATEDIFF(?, li.due_date) as days_overdue
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 < ?
@ -987,28 +1107,76 @@ public function overdueTransactions(Request $request)
ORDER BY li.due_date ASC
", [$today, $this->userId, $today]);
$totalOverdueConverted = 0;
$installmentsResult = array_map(function($row) use (&$totalOverdueConverted) {
foreach ($overdueInstallments as $row) {
$converted = $this->convertToPrimaryCurrency($row->amount, $row->currency);
$totalOverdueConverted += $converted;
return [
$result[] = [
'id' => $row->id,
'description' => $row->liability_name,
'description' => $row->description . ' (Cuota)',
'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',
'days_overdue' => (int) $row->days_overdue,
'source_type' => 'liability_installment',
'account' => $row->account_name,
'category' => null,
];
}, $overdueInstallments);
}
// 2. Transacciones pendientes/scheduled con fecha pasada
$overdueTransactions = DB::select("
SELECT
t.id,
t.description,
COALESCE(t.planned_amount, t.amount) as amount,
t.planned_date as due_date,
t.type,
t.status,
COALESCE(a.currency, 'EUR') as currency,
DATEDIFF(?, t.planned_date) as days_overdue,
a.name as account_name,
c.name as category_name
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ?
AND t.status IN ('pending', 'scheduled')
AND t.planned_date < ?
AND t.deleted_at IS NULL
ORDER BY t.planned_date ASC
", [$today, $this->userId, $today]);
foreach ($overdueTransactions as $row) {
$amount = abs($row->amount);
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
$totalOverdueConverted += $converted;
$result[] = [
'id' => $row->id,
'description' => $row->description,
'amount' => round($amount, 2),
'amount_converted' => round($converted, 2),
'currency' => $row->currency,
'due_date' => $row->due_date,
'days_overdue' => (int) $row->days_overdue,
'source_type' => 'transaction',
'type' => $row->type,
'status' => $row->status,
'account' => $row->account_name,
'category' => $row->category_name,
];
}
// Ordenar por días de atraso (más atrasado primero)
usort($result, fn($a, $b) => $b['days_overdue'] <=> $a['days_overdue']);
return response()->json([
'data' => $installmentsResult,
'data' => $result,
'currency' => $this->primaryCurrency,
'summary' => [
'total_overdue' => count($installmentsResult),
'total_overdue' => count($result),
'total_amount' => round($totalOverdueConverted, 2),
],
]);
@ -1124,4 +1292,100 @@ public function executiveSummary(Request $request)
}, $top5Categories),
]);
}
/**
* Calcula las próximas fechas de ejecución de una recurrencia dentro de un período
*/
private function getNextRecurrenceDates($recurrence, $startDate, $endDate)
{
$dates = [];
$start = Carbon::parse($startDate);
$end = Carbon::parse($endDate);
$recStart = Carbon::parse($recurrence->start_date);
$recEnd = $recurrence->end_date ? Carbon::parse($recurrence->end_date) : null;
// Si la recurrencia termina antes del período, no hay fechas
if ($recEnd && $recEnd->lt($start)) {
return $dates;
}
// Calcular la primera fecha dentro del período
$current = $recStart->copy();
// Avanzar hasta estar dentro del período
while ($current->lt($start)) {
$current = $this->advanceToNextOccurrence($current, $recurrence);
if ($current->gt($end)) {
return $dates;
}
}
// Generar fechas hasta el fin del período
$maxIterations = 100; // Prevenir bucles infinitos
$iterations = 0;
while ($current->lte($end) && $iterations < $maxIterations) {
// Verificar que no pase de la fecha de fin de la recurrencia
if ($recEnd && $current->gt($recEnd)) {
break;
}
$dates[] = $current->format('Y-m-d');
$current = $this->advanceToNextOccurrence($current, $recurrence);
$iterations++;
}
return $dates;
}
/**
* Avanza a la próxima ocurrencia según la frecuencia
*/
private function advanceToNextOccurrence($date, $recurrence)
{
$next = $date->copy();
switch ($recurrence->frequency) {
case 'daily':
$next->addDays($recurrence->frequency_interval ?? 1);
break;
case 'weekly':
$next->addWeeks($recurrence->frequency_interval ?? 1);
break;
case 'biweekly':
$next->addWeeks(2);
break;
case 'monthly':
$next->addMonths($recurrence->frequency_interval ?? 1);
if ($recurrence->day_of_month) {
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
}
break;
case 'bimonthly':
$next->addMonths(2);
if ($recurrence->day_of_month) {
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
}
break;
case 'quarterly':
$next->addMonths(3);
if ($recurrence->day_of_month) {
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
}
break;
case 'semiannual':
$next->addMonths(6);
if ($recurrence->day_of_month) {
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
}
break;
case 'annual':
$next->addYears($recurrence->frequency_interval ?? 1);
break;
default:
$next->addMonths(1);
}
return $next;
}
}