test: Fix Carbon month overflow in date calculations (#87)

## Summary
- Replace `subMonths()` with `subMonthsNoOverflow()` across controllers,
commands, and tests to prevent date overflow on months with 31 days
- On Jan 31, `subMonths(2)` overflows: Nov 31 doesn't exist → Carbon
rolls forward to Dec 1, producing wrong date ranges and off-by-one month
counts

## Root cause
Carbon's `subMonths()` allows day overflow. When the target month has
fewer days than the source date, it rolls into the next month. For
example:
- `2026-01-31 subMonths(2)` → Nov 31 → **2025-12-01** (expected Nov 30)
- `2026-01-31 subMonths(11)` → Feb 31 → **2025-03-03** (expected Feb 28)

`subMonthsNoOverflow()` clamps to the last valid day of the target month
instead.

## Files changed
- `app/Http/Controllers/Api/CashflowAnalyticsController.php` — cashflow
trend start date calculation
- `app/Console/Commands/ResetDemoAccountCommand.php` — balance history
date generation (was causing duplicate `balance_date` entries)
- `tests/Feature/AccountControllerTest.php` — balance evolution `from`
param
- `tests/Feature/CashflowAnalyticsTest.php` — transaction date
generation in loop
- `tests/Feature/DashboardAnalyticsTest.php` — net worth evolution
`from` param

## Test plan
- [x] `account balance evolution returns data` — expects 3 months, was
getting 2
- [x] `cashflow trend returns monthly data` — expects 3 months, was
getting 2
- [x] `cashflow trend defaults to 12 months` — expects 12 months, was
getting 11
- [x] `net worth evolution returns monthly data points` — expects 3
months, was getting 2
- [x] `demo:reset creates demo user` — was throwing
UniqueConstraintViolation on duplicate balance dates
This commit is contained in:
Víctor Falcón 2026-01-31 14:38:17 +01:00 committed by Víctor Falcón
parent 10bd7da5db
commit cc666a3111
5 changed files with 6 additions and 6 deletions

View File

@ -276,7 +276,7 @@ class ResetDemoAccountCommand extends Command
$balances = [];
for ($i = 0; $i <= 12; $i++) {
$date = now()->subMonths($i)->endOfMonth();
$date = now()->subMonthsNoOverflow($i)->endOfMonth();
if ($i === 0) {
$date = now();

View File

@ -67,7 +67,7 @@ class CashflowAnalyticsController extends Controller
$userId = $request->user()->id;
$end = Carbon::now()->endOfMonth();
$start = Carbon::now()->subMonths($months - 1)->startOfMonth();
$start = Carbon::now()->subMonthsNoOverflow($months - 1)->startOfMonth();
$data = [];
$current = $start->copy();

View File

@ -167,7 +167,7 @@ test('account balance evolution returns data for single account', function () {
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([
'from' => now()->subMonths(2)->startOfMonth()->toDateString(),
'from' => now()->subMonthsNoOverflow(2)->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));

View File

@ -223,14 +223,14 @@ test('cashflow trend returns monthly data for specified months', function () {
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 100000 + ($i * 10000),
'transaction_date' => now()->subMonths($i),
'transaction_date' => now()->subMonthsNoOverflow($i),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -(50000 + ($i * 5000)),
'transaction_date' => now()->subMonths($i),
'transaction_date' => now()->subMonthsNoOverflow($i),
]);
}

View File

@ -211,7 +211,7 @@ test('net worth evolution returns monthly data points with per-account balances'
]);
$response = $this->getJson('/api/dashboard/net-worth-evolution?'.http_build_query([
'from' => now()->subMonths(2)->startOfMonth()->toDateString(),
'from' => now()->subMonthsNoOverflow(2)->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));