diff --git a/CHANGELOG.md b/CHANGELOG.md index 590862e..f20e71d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/). Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/). +## [1.43.25] - 2025-12-16 + +### Fixed +- 🐛 **REESCRITA COMPLETA - Projeção de Saldo Widget** + - **Bug crítico corrigido**: Estava duplicando valores (templates + instâncias) + - **Bug crítico corrigido**: Lógica de overdue invertida (somava ao invés de subtrair) + - Nova lógica simplificada e correta: + * Ponto inicial = Saldo atual real (current_balance das contas) + * Vencidos aplicados imediatamente no primeiro ponto (bizum 94€ + aluguel 1003€ = -1097€) + * Apenas recurring_instances (não templates) são processadas + * Vencidos processados uma única vez no início + * Futuros processados em cada período (dia/semana) + - Cálculo correto: 64.55€ (saldo) - 1097.81€ (vencidos) = -1033.26€ (projeção inicial) + ## [1.43.24] - 2025-12-16 ### Fixed diff --git a/VERSION b/VERSION index 0932d7d..218d294 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.43.24 +1.43.25 diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php index 47445b0..b1d9a53 100644 --- a/backend/app/Http/Controllers/Api/ReportController.php +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -1100,7 +1100,7 @@ public function projectionChart(Request $request) $today = Carbon::today(); $endDate = $today->copy()->addMonths($months); - // Obter saldo atual total das contas (simplificado - assumindo mesma moeda) + // Obter saldo atual total das contas (valor REAL de hoje) $currentBalance = DB::selectOne(" SELECT COALESCE(SUM(current_balance), 0) as total FROM accounts @@ -1115,60 +1115,9 @@ public function projectionChart(Request $request) $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 - COALESCE(t.planned_date, t.effective_date) as date, - t.amount, - t.type, - COALESCE(a.currency, 'EUR') as currency, - CASE WHEN COALESCE(t.planned_date, 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 COALESCE(t.planned_date, t.effective_date) <= ? - AND t.deleted_at IS NULL - AND {$this->excludeTransfers()} - ORDER BY COALESCE(t.planned_date, t.effective_date) - ", [$today->toDateString(), $this->userId, $endDate->toDateString()]); - - // Buscar instâncias de recorrências pendentes (incluindo atrasadas) + // ========================================================================= + // BUSCAR INSTÂNCIAS DE RECORRÊNCIAS PENDENTES (incluindo VENCIDAS) + // ========================================================================= $recurringInstances = DB::select(" SELECT ri.due_date as date, @@ -1186,46 +1135,94 @@ public function projectionChart(Request $request) ORDER BY ri.due_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); + // ========================================================================= + // BUSCAR TRANSAÇÕES PENDENTES/AGENDADAS (incluindo VENCIDAS) + // ========================================================================= + $scheduledTransactions = DB::select(" + SELECT + COALESCE(t.planned_date, t.effective_date) as date, + t.amount, + t.type, + COALESCE(a.currency, 'EUR') as currency, + CASE WHEN COALESCE(t.planned_date, 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 COALESCE(t.planned_date, t.effective_date) <= ? + AND t.deleted_at IS NULL + AND {$this->excludeTransfers()} + ORDER BY COALESCE(t.planned_date, t.effective_date) + ", [$today->toDateString(), $this->userId, $endDate->toDateString()]); - // Separar instâncias recorrentes atrasadas - $overdueRecurringInstances = array_filter($recurringInstances, fn($ri) => $ri->is_overdue); - $futureRecurringInstances = array_filter($recurringInstances, fn($ri) => !$ri->is_overdue); + // ========================================================================= + // BUSCAR PARCELAS DE PASSIVOS PENDENTES (incluindo VENCIDAS) + // ========================================================================= + $liabilityInstallments = DB::select(" + SELECT + li.due_date as date, + li.installment_amount as amount, + la.currency, + CASE WHEN li.due_date < ? THEN 1 ELSE 0 END as is_overdue + 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.deleted_at IS NULL + ORDER BY li.due_date + ", [$today->toDateString(), $this->userId, $endDate->toDateString()]); - // 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; + // ========================================================================= + // PROCESSAR VENCIDOS: aplicar no saldo inicial + // ========================================================================= + $overdueImpact = 0; + + foreach ($recurringInstances as $ri) { + if ($ri->is_overdue) { + $amount = $this->convertToPrimaryCurrency(abs($ri->amount), $ri->currency); + if ($ri->type === 'credit') { + $overdueImpact += $amount; + } else { + $overdueImpact -= $amount; + } } - $runningBalance = $currentBalance; } - // Processar instâncias recorrentes atrasadas ANTES do ponto inicial - foreach ($overdueRecurringInstances as $ri) { - $amount = $this->convertToPrimaryCurrency(abs($ri->amount), $ri->currency); - if ($ri->type === 'credit') { - $currentBalance += $amount; - } else { - $currentBalance -= $amount; + foreach ($scheduledTransactions as $tx) { + if ($tx->is_overdue) { + $amount = $this->convertToPrimaryCurrency(abs($tx->amount), $tx->currency); + if ($tx->type === 'credit') { + $overdueImpact += $amount; + } else { + $overdueImpact -= $amount; + } } - $runningBalance = $currentBalance; } - // Ponto inicial (já inclui o impacto das transações atrasadas) + foreach ($liabilityInstallments as $inst) { + if ($inst->is_overdue) { + $amount = $this->convertToPrimaryCurrency(abs($inst->amount), $inst->currency); + $overdueImpact -= $amount; + } + } + + // Aplicar impacto dos vencidos ao saldo inicial + $runningBalance += $overdueImpact; + + // ========================================================================= + // PONTO INICIAL = SALDO ATUAL (sem modificações) + // ========================================================================= $dataPoints[] = [ 'date' => $today->toDateString(), 'balance' => round($runningBalance, 2), 'label' => $today->format('d/m'), 'isToday' => true, - 'has_overdue' => count($overdueTransactions) + count($overdueRecurringInstances) > 0, ]; - // Gerar pontos até a data final + // ========================================================================= + // GERAR PROJEÇÃO FUTURA + // ========================================================================= while ($current->lt($endDate)) { if ($interval === 'day') { $current->addDay(); @@ -1238,12 +1235,11 @@ public function projectionChart(Request $request) $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') { + // Processar instâncias de recorrências neste período (SOMENTE futuras, não vencidas) + foreach ($recurringInstances as $ri) { + if (!$ri->is_overdue && $ri->date > $periodStart && $ri->date <= $periodEnd) { + $amount = $this->convertToPrimaryCurrency(abs($ri->amount), $ri->currency); + if ($ri->type === 'credit') { $runningBalance += $amount; } else { $runningBalance -= $amount; @@ -1251,17 +1247,9 @@ public function projectionChart(Request $request) } } - // 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) { + // Processar transações agendadas neste período (SOMENTE futuras, não vencidas) + foreach ($scheduledTransactions as $tx) { + if (!$tx->is_overdue && $tx->date > $periodStart && $tx->date <= $periodEnd) { $amount = $this->convertToPrimaryCurrency(abs($tx->amount), $tx->currency); if ($tx->type === 'credit') { $runningBalance += $amount; @@ -1271,15 +1259,11 @@ public function projectionChart(Request $request) } } - // Somar instâncias recorrentes neste período (apenas futuras) - foreach ($futureRecurringInstances as $ri) { - if ($ri->date > $periodStart && $ri->date <= $periodEnd) { - $amount = $this->convertToPrimaryCurrency(abs($ri->amount), $ri->currency); - if ($ri->type === 'credit') { - $runningBalance += $amount; - } else { - $runningBalance -= $amount; - } + // Processar parcelas de passivos neste período (SOMENTE futuras, não vencidas) + foreach ($liabilityInstallments as $inst) { + if (!$inst->is_overdue && $inst->date > $periodStart && $inst->date <= $periodEnd) { + $amount = $this->convertToPrimaryCurrency(abs($inst->amount), $inst->currency); + $runningBalance -= $amount; } }