From 03fd41fda949b363aaa906d75bbf4089ce6ae765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Thu, 25 Jun 2026 10:24:05 +0200 Subject: [PATCH] refactor(ai): move backfill kickoff into a StartCategorizationBackfill action The consent controller owned gating, the count query, UUID generation, cache seeding and dispatch. Extract that into an App\Actions\Ai action so the controller just records consent and delegates, and the job-id/cache-key contract has a single owner. Add a test covering the no-paid-plan gate path. --- .../Ai/StartCategorizationBackfill.php | 49 +++++++++++++++++++ .../Controllers/Ai/AiConsentController.php | 46 ++--------------- ...ategorizeUncategorizedTransactionsTest.php | 18 +++++++ 3 files changed, 70 insertions(+), 43 deletions(-) create mode 100644 app/Actions/Ai/StartCategorizationBackfill.php diff --git a/app/Actions/Ai/StartCategorizationBackfill.php b/app/Actions/Ai/StartCategorizationBackfill.php new file mode 100644 index 00000000..b8506b66 --- /dev/null +++ b/app/Actions/Ai/StartCategorizationBackfill.php @@ -0,0 +1,49 @@ +gate->allows($user)) { + return null; + } + + $total = Transaction::query() + ->where('user_id', $user->id) + ->pendingAiCategorization() + ->count(); + + if ($total === 0) { + return null; + } + + $jobId = (string) Str::uuid(); + + Cache::put( + CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($jobId), + ['status' => 'pending', 'processed' => 0, 'total' => $total, 'applied' => 0], + now()->addHour(), + ); + + CategorizeUncategorizedTransactionsJob::dispatch($user, $jobId); + + return ['job_id' => $jobId, 'total' => $total]; + } +} diff --git a/app/Http/Controllers/Ai/AiConsentController.php b/app/Http/Controllers/Ai/AiConsentController.php index 8e6a3fa2..792df759 100644 --- a/app/Http/Controllers/Ai/AiConsentController.php +++ b/app/Http/Controllers/Ai/AiConsentController.php @@ -2,15 +2,10 @@ namespace App\Http\Controllers\Ai; +use App\Actions\Ai\StartCategorizationBackfill; use App\Http\Controllers\Controller; -use App\Jobs\CategorizeUncategorizedTransactionsJob; -use App\Models\Transaction; -use App\Models\User; -use App\Services\Ai\AiCategorizationGate; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Str; class AiConsentController extends Controller { @@ -18,14 +13,14 @@ class AiConsentController extends Controller * Record the user's broad "use AI to help understand my finances" consent * and kick off a backfill of their uncategorized transactions. */ - public function store(Request $request, AiCategorizationGate $gate): JsonResponse + public function store(Request $request, StartCategorizationBackfill $startBackfill): JsonResponse { $user = $request->user(); $user->recordAiConsent(); return response()->json([ 'consented' => true, - 'categorization' => $this->startCategorization($user, $gate), + 'categorization' => $startBackfill->handle($user), ]); } @@ -38,39 +33,4 @@ class AiConsentController extends Controller return response()->json(['consented' => false]); } - - /** - * Dispatch the backfill job when the user is eligible and has anything to - * categorize, returning the job id the client polls for progress. - * - * @return array{job_id: string, total: int}|null - */ - private function startCategorization(User $user, AiCategorizationGate $gate): ?array - { - if (! $gate->allows($user)) { - return null; - } - - $total = Transaction::query() - ->where('user_id', $user->id) - ->whereNull('category_id') - ->whereNull('description_iv') - ->count(); - - if ($total === 0) { - return null; - } - - $jobId = (string) Str::uuid(); - - Cache::put( - CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($jobId), - ['status' => 'pending', 'processed' => 0, 'total' => $total, 'applied' => 0], - now()->addHour(), - ); - - CategorizeUncategorizedTransactionsJob::dispatch($user, $jobId); - - return ['job_id' => $jobId, 'total' => $total]; - } } diff --git a/tests/Feature/Ai/CategorizeUncategorizedTransactionsTest.php b/tests/Feature/Ai/CategorizeUncategorizedTransactionsTest.php index 92aeb498..cb6dbbe3 100644 --- a/tests/Feature/Ai/CategorizeUncategorizedTransactionsTest.php +++ b/tests/Feature/Ai/CategorizeUncategorizedTransactionsTest.php @@ -48,6 +48,24 @@ it('does not dispatch a backfill when nothing is uncategorized', function () { Bus::assertNotDispatched(CategorizeUncategorizedTransactionsJob::class); }); +it('does not dispatch a backfill for a user without a paid plan', function () { + config(['subscriptions.enabled' => true]); + Bus::fake(); + $user = User::factory()->create(); + Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'category_id' => null, + ]); + + actingAs($user)->postJson(route('ai.consent.store')) + ->assertOk() + ->assertJson(['consented' => true]) + ->assertJsonPath('categorization', null); + + expect($user->hasActiveAiConsent())->toBeTrue(); + Bus::assertNotDispatched(CategorizeUncategorizedTransactionsJob::class); +}); + it('does not dispatch a backfill when AI categorization is disabled', function () { config(['ai_categorization.enabled' => false]); Bus::fake();