From fb21cef94615970ea5d60dc2527fee55b7e096f2 Mon Sep 17 00:00:00 2001 From: Walter Prorok Date: Sat, 25 Jul 2026 10:19:51 -0400 Subject: [PATCH] M72 Fix service class and command --- .../RollForwardRecurringTransactions.php | 52 +++++++--- app/Filament/Pages/MonthlyBudget.php | 29 +++--- app/Services/RecurringTransactionService.php | 66 ++++++------- .../RecurringTransactionServiceTest.php | 95 +++++++++++++------ 4 files changed, 149 insertions(+), 93 deletions(-) diff --git a/app/Console/Commands/RollForwardRecurringTransactions.php b/app/Console/Commands/RollForwardRecurringTransactions.php index 12be30c..10f7cc3 100644 --- a/app/Console/Commands/RollForwardRecurringTransactions.php +++ b/app/Console/Commands/RollForwardRecurringTransactions.php @@ -11,7 +11,8 @@ class RollForwardRecurringTransactions extends Command { protected $signature = 'app:roll-forward-recurring-transactions'; - protected $description = 'Backfill recurring transactions for all users'; + + protected $description = 'Prepare next month recurring transactions for all users'; public function handle(): void { @@ -19,30 +20,53 @@ public function handle(): void $users = User::all(); - $this->info("Processing {$users->count()} users"); + $referenceDate = now()->startOfMonth(); + + $targetPeriod = $referenceDate + ->copy() + ->addMonthNoOverflow() + ->format('F Y'); + + $this->info( + "Processing {$users->count()} users for {$targetPeriod}" + ); foreach ($users as $user) { try { + $created = $service->run( + userId: $user->id, + nextMonthOnly: true, + referenceDate: $referenceDate, + ); - $created = $service->run($user->id, false); + $this->line( + "User {$user->id}: {$created} created" + ); - $this->line("User {$user->id}: {$created} created"); - - Log::info('User processed', [ + Log::info('Recurring transactions prepared', [ 'user_id' => $user->id, + 'source_period' => $referenceDate->format('Y-m'), + 'target_period' => $referenceDate + ->copy() + ->addMonthNoOverflow() + ->format('Y-m'), 'created' => $created, ]); - } catch (Throwable $e) { - - Log::error('Recurring transaction failed for user', [ - 'user_id' => $user->id, - 'error' => $e->getMessage(), - ]); + Log::error( + 'Recurring transaction preparation failed', + [ + 'user_id' => $user->id, + 'source_period' => $referenceDate->format('Y-m'), + 'target_period' => $referenceDate + ->copy() + ->addMonthNoOverflow() + ->format('Y-m'), + 'error' => $e->getMessage(), + ] + ); $this->error("User {$user->id} failed"); - - continue; } } diff --git a/app/Filament/Pages/MonthlyBudget.php b/app/Filament/Pages/MonthlyBudget.php index 9e348eb..bcdcf7d 100644 --- a/app/Filament/Pages/MonthlyBudget.php +++ b/app/Filament/Pages/MonthlyBudget.php @@ -202,12 +202,14 @@ public function table(Table $table): Table protected function getHeaderActions(): array { - $selected = Carbon::create( + $selectedPeriod = Carbon::create( $this->getSelectedYear(), $this->getSelectedMonth() - ); + )->startOfDay(); - $nextPeriod = $selected->copy()->addMonth(); + $nextPeriod = $selectedPeriod + ->copy() + ->addMonthNoOverflow(); $nextPeriodLabel = $nextPeriod->format('F Y'); @@ -218,22 +220,17 @@ protected function getHeaderActions(): array ->color('success') ->requiresConfirmation() ->modalHeading("Prepare {$nextPeriodLabel}") - ->modalDescription("Recurring transactions will be carried forward into {$nextPeriodLabel} based on their frequency.") + ->modalDescription( + "Recurring transactions will be carried forward into {$nextPeriodLabel} based on their frequency." + ) ->modalSubmitActionLabel('Prepare') - ->action(function () use ($nextPeriodLabel) { - $referenceDate = Carbon::create( - $this->getSelectedYear(), - $this->getSelectedMonth(), - 1 + ->action(function () use ($selectedPeriod, $nextPeriodLabel) { + $count = app(RecurringTransactionService::class)->run( + userId: auth()->id(), + nextMonthOnly: true, + referenceDate: $selectedPeriod, ); - $count = app(RecurringTransactionService::class) - ->run( - auth()->id(), - true, - $referenceDate - ); - Notification::make() ->title("{$nextPeriodLabel} prepared") ->body("{$count} transactions created.") diff --git a/app/Services/RecurringTransactionService.php b/app/Services/RecurringTransactionService.php index 3341b9a..4dd0ed0 100644 --- a/app/Services/RecurringTransactionService.php +++ b/app/Services/RecurringTransactionService.php @@ -7,62 +7,66 @@ class RecurringTransactionService { - public function run( - int $userId, - bool $nextMonthOnly = false, - ?Carbon $referenceDate = null - ): int + public function run(int $userId, bool $nextMonthOnly = false, ?Carbon $referenceDate = null): int { $referenceDate ??= now(); - $now = $referenceDate; + $sourceStart = $referenceDate->copy()->startOfMonth(); + $sourceEnd = $referenceDate->copy()->endOfMonth(); - $nextMonth = $referenceDate->copy()->addMonth(); + $targetStart = $nextMonthOnly + ? $referenceDate->copy()->addMonthNoOverflow()->startOfMonth() + : $sourceStart->copy(); - $targetEnd = $nextMonthOnly - ? $nextMonth->copy()->endOfMonth() - : $referenceDate->copy()->endOfMonth(); + $targetEnd = $targetStart->copy()->endOfMonth(); + /* + * Only use transactions from the selected month as the source. + * + * For example, preparing August 2026 from July 2026 will only + * carry forward recurring transactions that exist in July 2026. + */ $transactions = Transaction::query() ->where('user_id', $userId) ->whereNotNull('recurring_rule') ->where('recurring_rule', '!=', 'once') + ->whereBetween('due_at', [$sourceStart, $sourceEnd]) ->orderBy('due_at') ->get(); $created = 0; foreach ($transactions as $transaction) { - - if (! $nextMonthOnly && $transaction->due_at->gt($now)) { - continue; - } - $currentDate = $transaction->due_at->copy(); $day = $transaction->due_at->day; - $iterations = 0; - while (true) { - - if ($iterations++ > 50) { - break; - } - + while ($iterations++ <= 50) { $nextDate = match ($transaction->recurring_rule) { 'weekly' => $currentDate->copy()->addWeek(), + 'biweekly' => $currentDate->copy()->addWeeks(2), + 'monthly' => tap( $currentDate->copy()->addMonthNoOverflow(), - fn($d) => $d->day(min($day, $d->copy()->endOfMonth()->day)) + fn(Carbon $date) => $date->day( + min($day, $date->copy()->endOfMonth()->day) + ) ), + 'quarterly' => tap( $currentDate->copy()->addMonthsNoOverflow(3), - fn($d) => $d->day(min($day, $d->copy()->endOfMonth()->day)) + fn(Carbon $date) => $date->day( + min($day, $date->copy()->endOfMonth()->day) + ) ), + 'yearly' => tap( $currentDate->copy()->addYearNoOverflow(), - fn($d) => $d->day(min($day, $d->copy()->endOfMonth()->day))), + fn(Carbon $date) => $date->day( + min($day, $date->copy()->endOfMonth()->day) + ) + ), default => null, }; @@ -71,8 +75,9 @@ public function run( break; } - if ($nextMonthOnly && $nextDate->format('Y-m') !== $nextMonth->format('Y-m')) { + if ($nextDate->lt($targetStart)) { $currentDate = $nextDate; + continue; } @@ -82,8 +87,8 @@ public function run( ->where('user_id', $transaction->user_id) ->where('merchant', $transaction->merchant) ->where('type', $transaction->type) - ->whereMonth('due_at', $nextDate->month) - ->whereYear('due_at', $nextDate->year); + ->whereYear('due_at', $nextDate->year) + ->whereMonth('due_at', $nextDate->month); if (config('database.default') === 'sqlite') { $query->whereRaw( @@ -100,11 +105,6 @@ public function run( $existing = $query->first(); if ($existing) { - if (! $nextMonthOnly) { - $currentDate = $nextDate; // move forward - continue; - } - $existing->update([ 'category_id' => $transaction->category_id, 'amount' => $transaction->amount, diff --git a/tests/Feature/RecurringTransactionServiceTest.php b/tests/Feature/RecurringTransactionServiceTest.php index cfb96bf..814c534 100644 --- a/tests/Feature/RecurringTransactionServiceTest.php +++ b/tests/Feature/RecurringTransactionServiceTest.php @@ -20,11 +20,12 @@ protected function setUp(): void { parent::setUp(); - Carbon::setTestNow(Carbon::create(2026, 4, 10)); // freeze time + Carbon::setTestNow(Carbon::create(2026, 4, 10)); + $this->service = new RecurringTransactionService(); } - public function test_it_creates_next_month_transaction_for_monthly_rule() + public function test_it_creates_next_month_transaction_for_monthly_rule(): void { $user = User::factory()->create(); @@ -36,30 +37,27 @@ public function test_it_creates_next_month_transaction_for_monthly_rule() 'user_id' => $user->id, 'category_id' => $category->id, 'recurring_rule' => 'monthly', - 'due_at' => now()->subMonth(), // March 10 + 'due_at' => Carbon::create(2026, 4, 10), 'amount' => 100, ]); - $created = $this->service->run($user->id, true); + $created = $this->service->run( + userId: $user->id, + nextMonthOnly: true, + referenceDate: Carbon::create(2026, 4, 1), + ); $this->assertEquals(1, $created); - $expectedDate = now() - ->copy() - ->addMonth() - ->startOfMonth() - ->addDays(9) - ->format('Y-m-d H:i:s'); // ✅ SQLite-safe - $this->assertDatabaseHas('transactions', [ 'user_id' => $user->id, 'merchant' => $transaction->merchant, 'amount' => 100, - 'due_at' => $expectedDate, + 'due_at' => '2026-05-10 00:00:00', ]); } - public function test_it_does_not_duplicate_existing_transaction_in_same_week() + public function test_it_does_not_duplicate_existing_transaction_in_same_week(): void { $user = User::factory()->create(); @@ -71,25 +69,27 @@ public function test_it_does_not_duplicate_existing_transaction_in_same_week() 'user_id' => $user->id, 'category_id' => $category->id, 'recurring_rule' => 'monthly', - 'due_at' => now()->subMonth()->startOfMonth()->addDays(4), // March 5 + 'due_at' => Carbon::create(2026, 4, 5), ]); - $nextDate = now()->copy()->addMonth()->startOfMonth()->addDays(6); // May 7 - Transaction::factory()->create([ 'user_id' => $user->id, 'category_id' => $category->id, 'merchant' => $original->merchant, 'type' => $original->type, - 'due_at' => $nextDate, + 'due_at' => Carbon::create(2026, 5, 7), ]); - $created = $this->service->run($user->id, true); + $created = $this->service->run( + userId: $user->id, + nextMonthOnly: true, + referenceDate: Carbon::create(2026, 4, 1), + ); $this->assertEquals(0, $created); } - public function test_it_does_not_modify_existing_is_paid_status() + public function test_it_does_not_modify_existing_is_paid_status(): void { $user = User::factory()->create(); @@ -97,23 +97,35 @@ public function test_it_does_not_modify_existing_is_paid_status() 'user_id' => $user->id, ]); - $transaction = Transaction::factory()->create([ + $source = Transaction::factory()->create([ 'user_id' => $user->id, 'category_id' => $category->id, 'recurring_rule' => 'monthly', - 'due_at' => now()->subMonth(), + 'due_at' => Carbon::create(2026, 4, 10), + ]); + + $existing = Transaction::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $category->id, + 'merchant' => $source->merchant, + 'type' => $source->type, + 'due_at' => Carbon::create(2026, 5, 10), 'is_paid' => true, ]); - $this->service->run($user->id, false); + $this->service->run( + userId: $user->id, + nextMonthOnly: true, + referenceDate: Carbon::create(2026, 4, 1), + ); $this->assertDatabaseHas('transactions', [ - 'id' => $transaction->id, + 'id' => $existing->id, 'is_paid' => true, ]); } - public function test_it_skips_future_transactions_when_not_next_month_mode() + public function test_it_does_not_use_transactions_outside_the_source_month(): void { $user = User::factory()->create(); @@ -125,15 +137,24 @@ public function test_it_skips_future_transactions_when_not_next_month_mode() 'user_id' => $user->id, 'category_id' => $category->id, 'recurring_rule' => 'monthly', - 'due_at' => now()->addMonth(), // future + 'due_at' => Carbon::create(2026, 3, 10), ]); - $created = $this->service->run($user->id, false); + $created = $this->service->run( + userId: $user->id, + nextMonthOnly: true, + referenceDate: Carbon::create(2026, 4, 1), + ); $this->assertEquals(0, $created); + + $this->assertDatabaseMissing('transactions', [ + 'user_id' => $user->id, + 'due_at' => '2026-05-10 00:00:00', + ]); } - public function test_it_handles_biweekly_transactions_correctly() + public function test_it_handles_biweekly_transactions_correctly(): void { $user = User::factory()->create(); @@ -145,11 +166,25 @@ public function test_it_handles_biweekly_transactions_correctly() 'user_id' => $user->id, 'category_id' => $category->id, 'recurring_rule' => 'biweekly', - 'due_at' => now()->subWeeks(2), + 'due_at' => Carbon::create(2026, 4, 20), ]); - $created = $this->service->run($user->id, true); + $created = $this->service->run( + userId: $user->id, + nextMonthOnly: true, + referenceDate: Carbon::create(2026, 4, 1), + ); + + $this->assertEquals(2, $created); - $this->assertGreaterThanOrEqual(1, $created); + $this->assertDatabaseHas('transactions', [ + 'user_id' => $user->id, + 'due_at' => '2026-05-04 00:00:00', + ]); + + $this->assertDatabaseHas('transactions', [ + 'user_id' => $user->id, + 'due_at' => '2026-05-18 00:00:00', + ]); } }