for($user)->create([ 'type' => CategoryType::Expense, 'cashflow_direction' => CategoryCashflowDirection::Outflow, ]); } function merchantTransaction(User $user, string $creditor): Transaction { return Transaction::factory()->plaintext()->create([ 'user_id' => $user->id, 'category_id' => null, 'amount' => -4300, 'creditor_name' => $creditor, 'description' => "{$creditor} compra", ]); } function outcome(Transaction $transaction, string $categoryId, float $confidence = 0.95, bool $unambiguous = true): CategorizationOutcome { return new CategorizationOutcome($transaction, $categoryId, $confidence, $unambiguous, true); } it('creates an ai-owned rule at the lowest priority and links the transaction', function () { $user = User::factory()->create(); $category = expenseCategory($user); AutomationRule::factory()->for($user)->create(['priority' => 5]); $transaction = merchantTransaction($user, 'Mercadona'); $rule = app(AiRuleLearner::class)->learn(outcome($transaction, $category->id)); expect($rule)->not->toBeNull() ->and($rule->origin)->toBe(RuleOrigin::Ai) ->and($rule->action_category_id)->toBe($category->id) ->and($rule->priority)->toBe(6) ->and($rule->rules_json)->toBe(['==' => [['var' => 'creditor_name'], 'mercadona']]) ->and($transaction->refresh()->categorized_by_rule_id)->toBe($rule->id); }); it('does not learn a rule from a confident suggestion that was not applied', function () { $user = User::factory()->create(); $category = expenseCategory($user); $transaction = merchantTransaction($user, 'Mercadona'); // Above rule_confidence and unambiguous, but the user's raised label bar kept // it from being applied (applied = false) — no forward rule should be learned. $notApplied = new CategorizationOutcome($transaction, $category->id, 0.9, true, false); expect(app(AiRuleLearner::class)->learn($notApplied))->toBeNull() ->and($transaction->refresh()->categorized_by_rule_id)->toBeNull(); }); it('resolves a fresh instance per container lookup so the memoized corpus cannot leak or go stale', function () { // The per-user corpus cache has no invalidation and is safe only while the // learner is never a singleton. Guard that invariant. expect(app(AiRuleLearner::class))->not->toBe(app(AiRuleLearner::class)); }); it('learns each correction correctly across a batch while loading the corpus once', function () { $user = User::factory()->create(); $target = expenseCategory($user); // A separate category keeps the corrected txns out of the "uncategorized" // count, so the overbroad guard (which needs uncategorized rows) is a no-op // and each correction actually learns a description rule. $existing = expenseCategory($user); // Merchant-less, plaintext transactions force the description-token path, // which is what loads the per-user description corpus. $makeTxn = fn (string $description): Transaction => Transaction::factory()->plaintext()->create([ 'user_id' => $user->id, 'category_id' => $existing->id, 'creditor_name' => null, 'debtor_name' => null, 'description' => $description, ]); $first = $makeTxn('Netflix subscription'); $second = $makeTxn('Spotify premium'); // One instance across the batch, mirroring the bulkUpdate loop where the // handler (and its learner) is resolved once. $learner = app(AiRuleLearner::class); DB::enableQueryLog(); $firstRule = $learner->learnFromCorrection($first, $target->id); $secondRule = $learner->learnFromCorrection($second, $target->id); $queries = collect(DB::getQueryLog()); DB::disableQueryLog(); // The second learning ran against the memoized corpus and still produced a // distinct, valid clause: both corrections live in the one target rule. expect($firstRule)->not->toBeNull() ->and($secondRule)->not->toBeNull() ->and($secondRule->id)->toBe($firstRule->id) ->and($secondRule->refresh()->rules_json)->toHaveKey('or') ->and($secondRule->rules_json['or'])->toHaveCount(2); // The corpus is the pluck of the `description` column (not the matcher's // count(*) probes, which also filter on description_iv), loaded once. $corpusLoads = $queries->filter(fn (array $q): bool => str_starts_with(strtolower(ltrim($q['query'])), 'select') && str_contains($q['query'], 'description_iv') && ! str_contains(strtolower($q['query']), 'count(') ); expect($corpusLoads)->toHaveCount(1); }); it('does not learn a description rule from a single short token', function () { $user = User::factory()->create(); $target = expenseCategory($user); // Corrected txn parked in another category, so the overbroad guard (which // measures uncategorized rows) is a no-op and only the token guard decides. $existing = expenseCategory($user); // Merchant-less so the description-token path runs. A lone short token like // "suc" (sucursal) is a generic banking abbreviation and must not become a // rule, even when it is rare in this user's corpus. $transaction = Transaction::factory()->plaintext()->create([ 'user_id' => $user->id, 'category_id' => $existing->id, 'creditor_name' => null, 'debtor_name' => null, 'description' => 'suc', ]); expect(app(AiRuleLearner::class)->learnFromCorrection($transaction, $target->id))->toBeNull(); }); it('learns a description rule from a single sufficiently long token', function () { $user = User::factory()->create(); $target = expenseCategory($user); $existing = expenseCategory($user); $transaction = Transaction::factory()->plaintext()->create([ 'user_id' => $user->id, 'category_id' => $existing->id, 'creditor_name' => null, 'debtor_name' => null, 'description' => 'netflix', ]); $rule = app(AiRuleLearner::class)->learnFromCorrection($transaction, $target->id); expect($rule)->not->toBeNull() ->and($rule->rules_json)->toBe(['in' => ['netflix', ['var' => 'description']]]); }); it('learns a description rule from two short tokens', function () { $user = User::factory()->create(); $target = expenseCategory($user); $existing = expenseCategory($user); $transaction = Transaction::factory()->plaintext()->create([ 'user_id' => $user->id, 'category_id' => $existing->id, 'creditor_name' => null, 'debtor_name' => null, 'description' => 'abc def', ]); $rule = app(AiRuleLearner::class)->learnFromCorrection($transaction, $target->id); expect($rule)->not->toBeNull() ->and($rule->rules_json)->toBe(['and' => [ ['in' => ['abc', ['var' => 'description']]], ['in' => ['def', ['var' => 'description']]], ]]); }); it('appends a new merchant to the existing ai rule for the same category', function () { $user = User::factory()->create(); $category = expenseCategory($user); $first = app(AiRuleLearner::class)->learn(outcome(merchantTransaction($user, 'Mercadona'), $category->id)); $second = app(AiRuleLearner::class)->learn(outcome(merchantTransaction($user, 'Carrefour'), $category->id)); expect($second->id)->toBe($first->id) ->and(AutomationRule::query()->where('user_id', $user->id)->count())->toBe(1) ->and($second->refresh()->rules_json)->toBe([ 'or' => [ ['==' => [['var' => 'creditor_name'], 'mercadona']], ['==' => [['var' => 'creditor_name'], 'carrefour']], ], ]); }); it('does not duplicate a merchant already on the rule', function () { $user = User::factory()->create(); $category = expenseCategory($user); app(AiRuleLearner::class)->learn(outcome(merchantTransaction($user, 'Mercadona'), $category->id)); $rule = app(AiRuleLearner::class)->learn(outcome(merchantTransaction($user, 'mercadona'), $category->id)); expect($rule->refresh()->rules_json)->toBe(['==' => [['var' => 'creditor_name'], 'mercadona']]); }); it('learns a rule that categorizes a future transaction from the same merchant', function () { $user = User::factory()->create(); $category = expenseCategory($user); app(AiRuleLearner::class)->learn(outcome(merchantTransaction($user, 'Mercadona'), $category->id)); $future = merchantTransaction($user, 'Mercadona'); app(AutomationRuleService::class)->applyRules($future); expect($future->refresh()->category_id)->toBe($category->id); }); it('does not learn an ambiguous merchant', function () { $user = User::factory()->create(); $category = expenseCategory($user); $rule = app(AiRuleLearner::class)->learn( outcome(merchantTransaction($user, 'Amazon'), $category->id, unambiguous: false), ); expect($rule)->toBeNull() ->and(AutomationRule::query()->where('user_id', $user->id)->count())->toBe(0); }); it('does not learn below the rule confidence bar', function () { $user = User::factory()->create(); $category = expenseCategory($user); $rule = app(AiRuleLearner::class)->learn( outcome(merchantTransaction($user, 'Mercadona'), $category->id, confidence: 0.8), ); expect($rule)->toBeNull(); }); it('does not learn when the transaction has no merchant key', function () { $user = User::factory()->create(); $category = expenseCategory($user); $transaction = Transaction::factory()->plaintext()->create([ 'user_id' => $user->id, 'category_id' => null, 'amount' => -1000, 'creditor_name' => null, 'debtor_name' => null, 'description' => 'card payment 1234', ]); expect(app(AiRuleLearner::class)->learn(outcome($transaction, $category->id)))->toBeNull(); }); it('never reuses a user-owned rule, even for the same category', function () { $user = User::factory()->create(); $category = expenseCategory($user); $userRule = AutomationRule::factory()->for($user)->create(['action_category_id' => $category->id]); $aiRule = app(AiRuleLearner::class)->learn(outcome(merchantTransaction($user, 'Mercadona'), $category->id)); expect($aiRule->id)->not->toBe($userRule->id) ->and($aiRule->origin)->toBe(RuleOrigin::Ai); });