diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index 6b11ef5f..7bb3a62a 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Enums\BankingConnectionStatus; +use App\Jobs\CategorizeOnboardingTransactionsJob; use App\Models\Bank; use App\Models\Category; use App\Models\Transaction; @@ -87,10 +88,14 @@ class OnboardingController extends Controller public function complete(Request $request): RedirectResponse { - $request->user()->update([ + $user = $request->user(); + + $user->update([ 'onboarded_at' => now(), ]); + CategorizeOnboardingTransactionsJob::dispatch($user); + return redirect()->route('dashboard'); } } diff --git a/app/Jobs/CategorizeOnboardingTransactionsJob.php b/app/Jobs/CategorizeOnboardingTransactionsJob.php new file mode 100644 index 00000000..1035a261 --- /dev/null +++ b/app/Jobs/CategorizeOnboardingTransactionsJob.php @@ -0,0 +1,63 @@ +allows($this->user)) { + return; + } + + $pendingIds = Transaction::query() + ->where('user_id', $this->user->id) + ->whereNull('category_id') + ->whereNull('description_iv') + ->pluck('id'); + + if ($pendingIds->isEmpty()) { + return; + } + + $batchSize = max(1, (int) config('ai_categorization.group_batch_size')); + + // Chunk a fixed snapshot of ids so transactions left blank (below the + // confidence bar) are never re-processed on a later iteration. + foreach ($pendingIds->chunk($batchSize) as $chunkIds) { + $chunk = Transaction::query()->whereIn('id', $chunkIds->all())->get(); + + $categorizer->run($this->user, new Collection($chunk->all())); + } + } +} diff --git a/app/Listeners/CategorizeTransactionWithAi.php b/app/Listeners/CategorizeTransactionWithAi.php index ef6fd667..7fa872d8 100644 --- a/app/Listeners/CategorizeTransactionWithAi.php +++ b/app/Listeners/CategorizeTransactionWithAi.php @@ -45,7 +45,19 @@ class CategorizeTransactionWithAi implements ShouldQueue $user = $transaction->user; - if ($user === null || ! $this->gate->allows($user)) { + if ($user === null) { + return; + } + + // Transactions imported during onboarding are deliberately skipped here: + // the bulk of them are covered by the AI automation rules generated at the + // end of onboarding, and whatever is left is categorized in a single batch + // pass once onboarding completes (CategorizeOnboardingTransactionsJob). + if (! $user->isOnboarded()) { + return; + } + + if (! $this->gate->allows($user)) { return; } diff --git a/tests/Feature/Ai/CategorizeOnboardingTransactionsJobTest.php b/tests/Feature/Ai/CategorizeOnboardingTransactionsJobTest.php new file mode 100644 index 00000000..2304aaa9 --- /dev/null +++ b/tests/Feature/Ai/CategorizeOnboardingTransactionsJobTest.php @@ -0,0 +1,117 @@ +onboarded()->create(); + $user->recordAiConsent(); + Feature::for($user)->activate(AiCategorization::class); + + return $user; +} + +function leafIndexFor(CategoryCatalog $catalog, string $categoryId): int +{ + $index = 0; + + while (($id = $catalog->categoryIdForIndex($index)) !== null) { + if ($id === $categoryId) { + return $index; + } + $index++; + } + + throw new RuntimeException("category {$categoryId} is not a leaf in the catalog"); +} + +function expenseLeaf(User $user): Category +{ + return Category::factory()->for($user)->create([ + 'type' => CategoryType::Expense, + 'cashflow_direction' => CategoryCashflowDirection::Outflow, + ]); +} + +function fakeCategorizesEachRef(int $index): void +{ + TransactionCategorizationAgent::fake(function (string $prompt) use ($index): array { + preg_match_all('/"ref":"([0-9a-f-]+)"/', $prompt, $matches); + + return ['results' => array_map(fn (string $ref): array => [ + 'ref' => $ref, + 'category_index' => $index, + 'confidence' => 0.95, + 'merchant_unambiguous' => false, + ], $matches[1])]; + }); +} + +function runOnboardingCategorization(User $user): void +{ + app()->call([new CategorizeOnboardingTransactionsJob($user), 'handle']); +} + +it('categorizes the remaining uncategorized transactions when onboarding completes', function () { + $user = eligibleOnboardedUser(); + $category = expenseLeaf($user); + $index = leafIndexFor(CategoryCatalog::forUser($user), $category->id); + + fakeCategorizesEachRef($index); + + $pending = Transaction::factory()->plaintext()->count(3)->create([ + 'user_id' => $user->id, + 'category_id' => null, + 'creditor_name' => 'mercadona', + ]); + + runOnboardingCategorization($user); + + foreach ($pending as $transaction) { + expect($transaction->refresh()->category_id)->toBe($category->id) + ->and($transaction->category_source)->toBe(CategorySource::Ai); + } +}); + +it('leaves transactions already categorized by rules untouched', function () { + $user = eligibleOnboardedUser(); + $category = expenseLeaf($user); + $index = leafIndexFor(CategoryCatalog::forUser($user), $category->id); + + fakeCategorizesEachRef($index); + + $alreadyCategorized = Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'category_id' => $category->id, + 'category_source' => CategorySource::Rule, + ]); + + runOnboardingCategorization($user); + + expect($alreadyCategorized->refresh()->category_source)->toBe(CategorySource::Rule); +}); + +it('does nothing for a user who is not eligible', function () { + $user = User::factory()->onboarded()->create(); + expenseLeaf($user); + + $transaction = Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'category_id' => null, + 'creditor_name' => 'mercadona', + ]); + + runOnboardingCategorization($user); + + expect($transaction->refresh()->category_id)->toBeNull(); +}); diff --git a/tests/Feature/Ai/CategorizeTransactionWithAiTest.php b/tests/Feature/Ai/CategorizeTransactionWithAiTest.php index 2419acdf..f93c1c43 100644 --- a/tests/Feature/Ai/CategorizeTransactionWithAiTest.php +++ b/tests/Feature/Ai/CategorizeTransactionWithAiTest.php @@ -13,7 +13,7 @@ use Laravel\Pennant\Feature; function eligible(): User { - $user = User::factory()->create(); + $user = User::factory()->onboarded()->create(); $user->recordAiConsent(); Feature::for($user)->activate(AiCategorization::class); @@ -87,6 +87,21 @@ it('does nothing when the user is not eligible', function () { expect($transaction->refresh()->category_id)->toBeNull(); }); +it('does not categorize transactions created while onboarding', function () { + $user = eligible(); + $user->update(['onboarded_at' => null]); + leaf($user); + + $transaction = Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'category_id' => null, + 'amount' => -4300, + 'creditor_name' => 'mercadona', + ]); + + expect($transaction->refresh()->category_id)->toBeNull(); +}); + it('does not categorize a transaction that already has a category', function () { $user = eligible(); $category = leaf($user); diff --git a/tests/Feature/Onboarding/OnboardingControllerTest.php b/tests/Feature/Onboarding/OnboardingControllerTest.php index 255b0159..a2521d2d 100644 --- a/tests/Feature/Onboarding/OnboardingControllerTest.php +++ b/tests/Feature/Onboarding/OnboardingControllerTest.php @@ -1,10 +1,12 @@ create(['onboarded_at' => null]); @@ -104,6 +106,23 @@ it('ignores an unknown step and falls back to the default flow', function () { ); }); +it('marks the user onboarded and queues the AI categorization batch on complete', function () { + Queue::fake(); + + $user = User::factory()->create(['onboarded_at' => null]); + + $this->actingAs($user) + ->post('/onboarding/complete') + ->assertRedirect(route('dashboard')); + + expect($user->refresh()->onboarded_at)->not->toBeNull(); + + Queue::assertPushed( + CategorizeOnboardingTransactionsJob::class, + fn (CategorizeOnboardingTransactionsJob $job): bool => $job->user->is($user), + ); +}); + it('returns banks and accounts props on onboarding index', function () { $user = User::factory()->create(['onboarded_at' => null]); $globalBank = Bank::factory()->create(['user_id' => null]);