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:
parent
8013a0b6f2
commit
e065d4ab65
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue