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.
This commit is contained in:
Víctor Falcón 2026-06-25 10:24:05 +02:00
parent 3126f1763f
commit 03fd41fda9
3 changed files with 70 additions and 43 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Actions\Ai;
use App\Jobs\CategorizeUncategorizedTransactionsJob;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class StartCategorizationBackfill
{
public function __construct(private readonly AiCategorizationGate $gate) {}
/**
* Dispatch a categorization backfill when the user is eligible and has
* something to categorize, seeding the progress cache the client polls.
*
* @return array{job_id: string, total: int}|null
*/
public function handle(User $user): ?array
{
if (! $this->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];
}
}

View File

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

View File

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