whisper-money/tests/Feature/Ai/ApplyRuleSuggestionsTest.php

139 lines
5.9 KiB
PHP

<?php
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\ApplyRuleSuggestions;
beforeEach(function () {
$this->user = User::factory()->create();
$this->account = Account::factory()->for($this->user)->create();
$this->action = app(ApplyRuleSuggestions::class);
$this->makeTxn = function (array $attributes): Transaction {
return Transaction::factory()->for($this->user)->create(array_merge([
'account_id' => $this->account->id,
'category_id' => null,
'description_iv' => null,
], $attributes));
};
$this->group = fn (array $overrides = []): array => array_merge([
'conditions' => [['field' => 'creditor_name', 'operator' => 'equals', 'token' => 'mercadona']],
'proposed_category_id' => null,
'new_category_name' => null,
'new_category_direction' => null,
'confidence' => 0.95,
], $overrides);
});
it('creates a rule and categorizes matching uncategorized transactions', function () {
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
$transactions = collect(range(1, 5))->map(fn (int $i) => ($this->makeTxn)([
'creditor_name' => 'MERCADONA',
'description' => "MERCADONA {$i}",
'amount' => -4000,
]));
$result = $this->action->apply($this->user, [
($this->group)(['proposed_category_id' => $groceries->id]),
], applyToExisting: true);
expect($result)->toBe(['rules_created' => 1, 'transactions_categorized' => 5]);
$rule = AutomationRule::query()->where('user_id', $this->user->id)->first();
expect($rule->action_category_id)->toBe($groceries->id)
->and($rule->rules_json)->toBe(['==' => [['var' => 'creditor_name'], 'mercadona']]);
$transactions->each(fn (Transaction $t) => expect($t->fresh()->category_id)->toBe($groceries->id));
});
it('merges values heading to the same category into one OR rule', function () {
$online = Category::factory()->for($this->user)->create(['name' => 'Online services', 'type' => 'expense']);
collect(range(1, 2))->each(fn (int $i) => ($this->makeTxn)(['creditor_name' => null, 'description' => "LARAVEL FORGE {$i}", 'amount' => -1200]));
collect(range(1, 3))->each(fn (int $i) => ($this->makeTxn)(['creditor_name' => null, 'description' => "DIGITALOCEAN {$i}", 'amount' => -500]));
$result = $this->action->apply($this->user, [
($this->group)([
'conditions' => [
['field' => 'description', 'operator' => 'contains', 'token' => 'laravel forge'],
['field' => 'description', 'operator' => 'contains', 'token' => 'digitalocean'],
],
'proposed_category_id' => $online->id,
]),
], applyToExisting: true);
expect($result)->toBe(['rules_created' => 1, 'transactions_categorized' => 5]);
$rule = AutomationRule::query()->where('user_id', $this->user->id)->first();
expect($rule->rules_json)->toBe(['or' => [
['in' => ['laravel forge', ['var' => 'description']]],
['in' => ['digitalocean', ['var' => 'description']]],
]]);
});
it('creates a proposed new category before applying the rule', function () {
collect(range(1, 4))->each(fn (int $i) => ($this->makeTxn)([
'creditor_name' => null,
'description' => "NETFLIX {$i}",
'amount' => -1300,
]));
$result = $this->action->apply($this->user, [
($this->group)([
'conditions' => [['field' => 'description', 'operator' => 'contains', 'token' => 'netflix']],
'new_category_name' => 'Streaming',
'new_category_direction' => 'outflow',
'confidence' => 0.9,
]),
], applyToExisting: true);
$category = Category::query()->where('user_id', $this->user->id)->where('name', 'Streaming')->first();
expect($category)->not->toBeNull()
->and($category->type->value)->toBe('expense')
->and($category->cashflow_direction->value)->toBe('outflow')
->and($result['transactions_categorized'])->toBe(4);
});
it('creates the rule but does not categorize when applyToExisting is false', function () {
$groceries = Category::factory()->for($this->user)->create(['type' => 'expense']);
$txn = ($this->makeTxn)(['creditor_name' => 'MERCADONA', 'description' => 'MERCADONA', 'amount' => -4000]);
$result = $this->action->apply($this->user, [
($this->group)(['proposed_category_id' => $groceries->id]),
], applyToExisting: false);
expect($result['rules_created'])->toBe(1)
->and($result['transactions_categorized'])->toBe(0)
->and($txn->fresh()->category_id)->toBeNull();
});
it('lets the more specific rule win an overlapping transaction', function () {
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
$streaming = Category::factory()->for($this->user)->create(['name' => 'Streaming', 'type' => 'expense']);
// Broad "mercadona" matches 3 transactions; narrow "netflix" matches only the bundle.
$bundle = ($this->makeTxn)(['creditor_name' => null, 'description' => 'MERCADONA NETFLIX BUNDLE', 'amount' => -4000]);
collect(range(1, 2))->each(fn (int $i) => ($this->makeTxn)(['creditor_name' => null, 'description' => "MERCADONA {$i}", 'amount' => -4000]));
$broad = ($this->group)([
'conditions' => [['field' => 'description', 'operator' => 'contains', 'token' => 'mercadona']],
'proposed_category_id' => $groceries->id,
'confidence' => 0.95,
]);
$narrow = ($this->group)([
'conditions' => [['field' => 'description', 'operator' => 'contains', 'token' => 'netflix']],
'proposed_category_id' => $streaming->id,
'confidence' => 0.80,
]);
$this->action->apply($this->user, [$broad, $narrow], applyToExisting: true);
expect($bundle->fresh()->category_id)->toBe($streaming->id);
});