set('ai_suggestions.eligibility_min_transactions', 50); config()->set('ai_suggestions.confidence_floor', 0.7); config()->set('ai_suggestions.overbroad_fraction', 0.4); config()->set('ai_suggestions.min_match_count', 1); $this->user = User::factory()->notOnboarded()->create(); $this->account = Account::factory()->for($this->user)->create(); }); function seedTransactions(User $user, Account $account, int $mercadona = 6, int $filler = 44): void { for ($i = 0; $i < $mercadona; $i++) { Transaction::factory()->for($user)->create([ 'account_id' => $account->id, 'category_id' => null, 'description_iv' => null, 'creditor_name' => 'MERCADONA', 'description' => "MERCADONA {$i}", 'amount' => -4000, ]); } for ($i = 0; $i < $filler; $i++) { Transaction::factory()->for($user)->create([ 'account_id' => $account->id, 'category_id' => null, 'description_iv' => null, 'creditor_name' => null, 'description' => "UNIQUE MERCHANT {$i}", 'amount' => -1000, ]); } } function fakeGeneratorReturning(string $categoryId): void { app()->instance(RuleSuggestionGenerator::class, new class($categoryId) implements RuleSuggestionGenerator { public function __construct(private string $categoryId) {} public function generate(array $groups, array $categoryOptions): array { return [[ 'group_key' => 'mercadona', 'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona', 'category_id' => $this->categoryId, 'confidence' => 0.95, ]]; } }); } it('blocks generation without consent', function () { $this->actingAs($this->user) ->postJson(route('ai.rule-suggestions.generate')) ->assertForbidden(); }); it('reports ineligible users with too few transactions', function () { $this->user->recordAiConsent(); seedTransactions($this->user, $this->account, mercadona: 3, filler: 5); // 8 total $this->actingAs($this->user) ->postJson(route('ai.rule-suggestions.generate')) ->assertStatus(422) ->assertJson(['eligible' => false, 'transaction_count' => 8]); }); it('generates, persists and returns suggestions for an eligible user', function () { $groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']); $this->user->recordAiConsent(); seedTransactions($this->user, $this->account); fakeGeneratorReturning($groceries->id); $response = $this->actingAs($this->user) ->postJson(route('ai.rule-suggestions.generate')) ->assertOk() ->assertJsonPath('run.status', SuggestionRunStatus::Completed->value) ->assertJsonPath('run.suggestions_count', 1) ->assertJsonPath('suggestions.0.values.0.match_token', 'mercadona'); expect($response->json('suggestions.0.proposed_category.id'))->toBe($groceries->id); }); it('groups pending suggestions that share a category into one card', function () { $groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']); $this->user->recordAiConsent(); seedTransactions($this->user, $this->account); // 6 MERCADONA + 44 UNIQUE MERCHANT $run = SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]); RuleSuggestion::factory()->for($run, 'run')->create([ 'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona', 'proposed_category_id' => $groceries->id, 'group_size' => 6, ]); RuleSuggestion::factory()->for($run, 'run')->create([ 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant', 'proposed_category_id' => $groceries->id, 'group_size' => 44, ]); $response = $this->actingAs($this->user) ->getJson(route('ai.rule-suggestions.show')) ->assertOk(); expect($response->json('suggestions'))->toHaveCount(1) ->and($response->json('suggestions.0.values'))->toHaveCount(2) ->and($response->json('suggestions.0.group_size'))->toBe(50) ->and($response->json('suggestions.0.proposed_category.id'))->toBe($groceries->id); }); it('hides suggestions matching fewer transactions than the configured minimum', function () { config()->set('ai_suggestions.min_match_count', 10); $groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']); $shopping = Category::factory()->for($this->user)->create(['name' => 'Shopping', 'type' => 'expense']); $this->user->recordAiConsent(); seedTransactions($this->user, $this->account); // 6 MERCADONA + 44 UNIQUE MERCHANT $run = SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]); RuleSuggestion::factory()->for($run, 'run')->create([ 'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona', 'proposed_category_id' => $groceries->id, 'group_size' => 6, ]); RuleSuggestion::factory()->for($run, 'run')->create([ 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant', 'proposed_category_id' => $shopping->id, 'group_size' => 44, ]); $response = $this->actingAs($this->user) ->getJson(route('ai.rule-suggestions.show')) ->assertOk(); // The MERCADONA card matches only 6 transactions (< 10) and is dropped. expect($response->json('suggestions'))->toHaveCount(1) ->and($response->json('suggestions.0.group_size'))->toBe(44) ->and($response->json('suggestions.0.proposed_category.id'))->toBe($shopping->id); }); it('shows every suggestion when the minimum match count is one', function () { config()->set('ai_suggestions.min_match_count', 1); $groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']); $shopping = Category::factory()->for($this->user)->create(['name' => 'Shopping', 'type' => 'expense']); $this->user->recordAiConsent(); seedTransactions($this->user, $this->account); $run = SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]); RuleSuggestion::factory()->for($run, 'run')->create([ 'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona', 'proposed_category_id' => $groceries->id, 'group_size' => 6, ]); RuleSuggestion::factory()->for($run, 'run')->create([ 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant', 'proposed_category_id' => $shopping->id, 'group_size' => 44, ]); $response = $this->actingAs($this->user) ->getJson(route('ai.rule-suggestions.show')) ->assertOk(); expect($response->json('suggestions'))->toHaveCount(2); }); it('reuses the latest run while throttled instead of generating again', function () { $this->user->recordAiConsent(); seedTransactions($this->user, $this->account); SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]); $this->actingAs($this->user) ->postJson(route('ai.rule-suggestions.generate')) ->assertOk() ->assertJson(['throttled' => true]); expect($this->user->suggestionRuns()->count())->toBe(1); }); it('previews the transactions a group of conditions would match', function () { $this->user->recordAiConsent(); seedTransactions($this->user, $this->account); $this->actingAs($this->user) ->postJson(route('ai.rule-suggestions.preview'), [ 'conditions' => [ ['match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona'], ['match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant 43'], ], ]) ->assertOk() ->assertJson(['match_count' => 7, 'total_uncategorized' => 50]); }); it('accepts suggestions and applies them immediately during onboarding', function () { $groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']); $this->user->recordAiConsent(); seedTransactions($this->user, $this->account); fakeGeneratorReturning($groceries->id); $generated = $this->actingAs($this->user)->postJson(route('ai.rule-suggestions.generate'))->json(); $ids = collect($generated['suggestions'][0]['values'])->pluck('id')->all(); $this->actingAs($this->user) ->postJson(route('ai.rule-suggestions.accept'), [ 'suggestions' => [[ 'ids' => $ids, 'values' => [[ 'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona', ]], 'proposed_category_id' => $groceries->id, ]], ]) ->assertOk() ->assertJson([ 'summary' => ['rules_created' => 1, 'transactions_categorized' => 6], 'applied_to_existing' => true, ]); expect(AutomationRule::query()->where('user_id', $this->user->id)->count())->toBe(1) ->and(Transaction::query()->where('user_id', $this->user->id)->where('creditor_name', 'MERCADONA')->whereNotNull('category_id')->count())->toBe(6); }); it('does not flag an upgrade when subscriptions are disabled', function () { config()->set('subscriptions.enabled', false); $this->actingAs($this->user) ->getJson(route('ai.rule-suggestions.show')) ->assertOk() ->assertJson(['requires_upgrade' => false]); }); it('flags an upgrade for free users without a connected account', function () { config()->set('subscriptions.enabled', true); $this->actingAs($this->user) ->getJson(route('ai.rule-suggestions.show')) ->assertOk() ->assertJson(['requires_upgrade' => true]); }); it('does not flag an upgrade when the user already linked a bank', function () { config()->set('subscriptions.enabled', true); BankingConnection::factory()->for($this->user)->create(); $this->actingAs($this->user) ->getJson(route('ai.rule-suggestions.show')) ->assertOk() ->assertJson(['requires_upgrade' => false]); }); it('does not flag an upgrade for subscribed users', function () { config()->set('subscriptions.enabled', true); $this->user->subscriptions()->create([ 'type' => 'default', 'stripe_id' => 'sub_test123', 'stripe_status' => 'active', 'stripe_price' => 'price_test123', ]); $this->actingAs($this->user) ->getJson(route('ai.rule-suggestions.show')) ->assertOk() ->assertJson(['requires_upgrade' => false]); }); it('records and revokes consent', function () { $this->actingAs($this->user) ->postJson(route('ai.consent.store')) ->assertOk() ->assertJson(['consented' => true]); expect($this->user->hasActiveAiConsent())->toBeTrue(); $this->actingAs($this->user) ->deleteJson(route('ai.consent.destroy')) ->assertOk() ->assertJson(['consented' => false]); expect($this->user->fresh()->hasActiveAiConsent())->toBeFalse(); });