From 1feb3354ea0188b070716c38e3b11ff0f73f49dd Mon Sep 17 00:00:00 2001 From: marcoitaloesp-ai Date: Sun, 14 Dec 2025 19:20:06 +0000 Subject: [PATCH] fix(reports): corrigir future-transactions e overdue - v1.32.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 22 ++ VERSION | 2 +- .../Http/Controllers/Api/ReportController.php | 320 ++++++++++++++++-- 3 files changed, 315 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e071f2..fa19644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 96cd6ee..c78d39b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.32.1 +1.32.2 diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php index 772fb81..f917bfb 100644 --- a/backend/app/Http/Controllers/Api/ReportController.php +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -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; + } }