273 lines
11 KiB
PHP
273 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Enums\CategoryCashflowDirection;
|
|
use App\Enums\CategoryType;
|
|
use App\Enums\RuleOrigin;
|
|
use App\Models\AutomationRule;
|
|
use App\Models\Category;
|
|
use App\Models\Transaction;
|
|
use App\Models\User;
|
|
use App\Services\Ai\AiRuleLearner;
|
|
use App\Services\Ai\CategorizationOutcome;
|
|
use App\Services\AutomationRuleService;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
function expenseCategory(User $user): Category
|
|
{
|
|
return Category::factory()->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);
|
|
});
|