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