feat(ai): defer per-transaction categorization until onboarding completes (#536)

## What

During onboarding, the per-transaction AI categorization listener no
longer runs. The AI automation rules generated in the suggestions step
cover the bulk of the imported transactions (~70%). On completion, a
single batch pass categorizes whatever is still uncategorized.

## Why

Running per-transaction categorization on every transaction created
during onboarding is wasteful: most are about to be covered by the AI
rules generated at the end of the flow. Defer the AI spend to one batch
over the remainder.

## How

- **`CategorizeTransactionWithAi` listener** — skips when `!
$user->isOnboarded()`. Covers every txn-creation path, since it hangs
off the model `created` event.
- **`CategorizeOnboardingTransactionsJob`** (new) — dispatched from
`OnboardingController::complete` after `onboarded_at` is set. Snapshots
`whereNull('category_id')->whereNull('description_iv')`, so transactions
already categorized by the AI rules (or manually in the categorize step)
are excluded. Chunks by `group_batch_size` and runs `AiCategorizer` per
chunk. Runs on the dedicated `ai` queue.
- Gated by `AiCategorizationGate::allows()` (rollout flag) — automatic
system behavior, gated like the real-time tier, not the explicit
backfill.

## Flow

1. `syncing` → transactions created, **no** per-transaction AI
2. `ai-suggestions` → AI rules cover the bulk
3. `categorize-transactions` → user manually categorizes a few
4. `complete` → batch-categorize the rest, skipping anything already
labeled

## Tests

- Listener skips transactions created while onboarding; still
categorizes post-onboarding.
- Job categorizes the remainder, leaves rule/manual categories
untouched, no-ops for ineligible users.
- `complete` marks the user onboarded and queues the batch job.

Full Ai + Onboarding suites green (112/112). Pint clean.
This commit is contained in:
Víctor Falcón 2026-06-15 17:33:26 +02:00 committed by GitHub
parent 8013a0b6f2
commit e065d4ab65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 234 additions and 3 deletions

View File

@ -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');
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Jobs;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use App\Services\Ai\AiCategorizer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Queue\Queueable;
/**
* One-shot AI categorization of everything still uncategorized when onboarding
* completes. The per-transaction listener is skipped during onboarding, so the
* AI rules generated in the suggestions step get first crack at the import (they
* cover the bulk of it); this pass then labels whatever the rules left blank.
*
* Runs on the dedicated AI queue so it never delays the rest of the app.
*/
class CategorizeOnboardingTransactionsJob implements ShouldQueue
{
use Queueable;
/**
* A fresh import can span many model calls, so give the batch plenty of room.
*/
public int $timeout = 300;
public function __construct(public User $user) {}
public function viaQueue(): string
{
return (string) config('ai_categorization.queue');
}
public function handle(AiCategorizationGate $gate, AiCategorizer $categorizer): void
{
if (! $gate->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()));
}
}
}

View File

@ -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;
}

View File

@ -0,0 +1,117 @@
<?php
use App\Ai\Agents\TransactionCategorizationAgent;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategorySource;
use App\Enums\CategoryType;
use App\Features\AiCategorization;
use App\Jobs\CategorizeOnboardingTransactionsJob;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\CategoryCatalog;
use Laravel\Pennant\Feature;
function eligibleOnboardedUser(): User
{
$user = User::factory()->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();
});

View File

@ -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);

View File

@ -1,10 +1,12 @@
<?php
use App\Jobs\CategorizeOnboardingTransactionsJob;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
it('returns categories and transactions props on onboarding index', function () {
$user = User::factory()->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]);