383 lines
13 KiB
PHP
383 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Enums\CategoryCashflowDirection;
|
|
use App\Enums\CategorySource;
|
|
use App\Enums\CategoryType;
|
|
use App\Enums\RuleOrigin;
|
|
use App\Models\AutomationRule;
|
|
use App\Models\Category;
|
|
use App\Models\CategoryCorrection;
|
|
use App\Models\Transaction;
|
|
use App\Models\User;
|
|
use App\Services\Ai\AiRuleLearner;
|
|
use App\Services\Ai\CategorizationOutcome;
|
|
use App\Services\Ai\CategoryOverrideHandler;
|
|
use App\Services\AutomationRuleService;
|
|
|
|
function cohCategory(User $user): Category
|
|
{
|
|
return Category::factory()->for($user)->create([
|
|
'type' => CategoryType::Expense,
|
|
'cashflow_direction' => CategoryCashflowDirection::Outflow,
|
|
]);
|
|
}
|
|
|
|
function cohMerchantTxn(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 cohLearnRule(User $user, string $categoryId, string $creditor): AutomationRule
|
|
{
|
|
return app(AiRuleLearner::class)->learn(
|
|
new CategorizationOutcome(cohMerchantTxn($user, $creditor), $categoryId, 0.95, true, true),
|
|
);
|
|
}
|
|
|
|
function cohMatched(User $user, string $creditor): Transaction
|
|
{
|
|
$transaction = cohMerchantTxn($user, $creditor);
|
|
app(AutomationRuleService::class)->applyRules($transaction);
|
|
|
|
return $transaction->refresh();
|
|
}
|
|
|
|
it('logs a correction and deletes the ai rule when its only merchant is corrected', function () {
|
|
$user = User::factory()->create();
|
|
$from = cohCategory($user);
|
|
$to = cohCategory($user);
|
|
|
|
$rule = cohLearnRule($user, $from->id, 'Mercadona');
|
|
$matched = cohMatched($user, 'Mercadona');
|
|
|
|
expect($matched->category_source)->toBe(CategorySource::Rule)
|
|
->and($matched->categorized_by_rule_id)->toBe($rule->id);
|
|
|
|
app(CategoryOverrideHandler::class)->record($matched, $to->id);
|
|
|
|
$correction = CategoryCorrection::query()->firstOrFail();
|
|
|
|
expect($correction->from_category_id)->toBe($from->id)
|
|
->and($correction->to_category_id)->toBe($to->id)
|
|
->and($correction->source)->toBe(CategorySource::Rule)
|
|
->and(AutomationRule::query()->find($rule->id))->toBeNull();
|
|
});
|
|
|
|
it('drops only the corrected merchant from a multi-merchant ai rule', function () {
|
|
$user = User::factory()->create();
|
|
$from = cohCategory($user);
|
|
$to = cohCategory($user);
|
|
|
|
cohLearnRule($user, $from->id, 'Mercadona');
|
|
$rule = cohLearnRule($user, $from->id, 'Carrefour');
|
|
$matched = cohMatched($user, 'Mercadona');
|
|
|
|
app(CategoryOverrideHandler::class)->record($matched, $to->id);
|
|
|
|
expect($rule->refresh()->rules_json)->toBe(['==' => [['var' => 'creditor_name'], 'carrefour']])
|
|
->and(CategoryCorrection::query()->count())->toBe(1);
|
|
});
|
|
|
|
it('logs a correction for a direct ai label without any rule', function () {
|
|
$user = User::factory()->create();
|
|
$from = cohCategory($user);
|
|
$to = cohCategory($user);
|
|
|
|
$transaction = Transaction::factory()->plaintext()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => $from->id,
|
|
'category_source' => CategorySource::Ai,
|
|
'ai_confidence' => 0.91,
|
|
]);
|
|
|
|
app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
|
|
|
$correction = CategoryCorrection::query()->firstOrFail();
|
|
|
|
expect($correction->source)->toBe(CategorySource::Ai)
|
|
->and($correction->confidence)->toEqual(0.91);
|
|
});
|
|
|
|
it('ignores corrections to a user-owned rule', function () {
|
|
$user = User::factory()->create();
|
|
$from = cohCategory($user);
|
|
$to = cohCategory($user);
|
|
|
|
$rule = AutomationRule::factory()->for($user)->create(['action_category_id' => $from->id]);
|
|
$transaction = Transaction::factory()->plaintext()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => $from->id,
|
|
'category_source' => CategorySource::Rule,
|
|
'categorized_by_rule_id' => $rule->id,
|
|
]);
|
|
|
|
app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
|
|
|
expect(CategoryCorrection::query()->count())->toBe(0)
|
|
->and(AutomationRule::query()->find($rule->id))->not->toBeNull();
|
|
});
|
|
|
|
it('ignores corrections to a manual category', function () {
|
|
$user = User::factory()->create();
|
|
$from = cohCategory($user);
|
|
$to = cohCategory($user);
|
|
|
|
$transaction = Transaction::factory()->plaintext()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => $from->id,
|
|
'category_source' => CategorySource::Manual,
|
|
]);
|
|
|
|
app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
|
|
|
expect(CategoryCorrection::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('does nothing when the category is unchanged', function () {
|
|
$user = User::factory()->create();
|
|
$category = cohCategory($user);
|
|
|
|
$transaction = Transaction::factory()->plaintext()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => $category->id,
|
|
'category_source' => CategorySource::Ai,
|
|
'ai_confidence' => 0.9,
|
|
]);
|
|
|
|
app(CategoryOverrideHandler::class)->record($transaction, $category->id);
|
|
|
|
expect(CategoryCorrection::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('learns a forward rule from an ai correction so the next merchant transaction skips ai', function () {
|
|
$user = User::factory()->create();
|
|
$wrong = cohCategory($user);
|
|
$right = cohCategory($user);
|
|
|
|
cohLearnRule($user, $wrong->id, 'Mercadona');
|
|
$matched = cohMatched($user, 'Mercadona');
|
|
|
|
$learned = app(CategoryOverrideHandler::class)->record($matched, $right->id);
|
|
|
|
expect($learned)->not->toBeNull()
|
|
->and($learned->origin)->toBe(RuleOrigin::Correction)
|
|
->and($learned->action_category_id)->toBe($right->id)
|
|
->and($learned->rules_json)->toBe(['==' => [['var' => 'creditor_name'], 'mercadona']]);
|
|
|
|
$next = cohMerchantTxn($user, 'Mercadona');
|
|
app(AutomationRuleService::class)->applyRules($next);
|
|
$next->refresh();
|
|
|
|
expect($next->category_id)->toBe($right->id)
|
|
->and($next->category_source)->toBe(CategorySource::Rule)
|
|
->and($next->categorized_by_rule_id)->toBe($learned->id);
|
|
});
|
|
|
|
it('learns a description rule when there is no merchant key', function () {
|
|
$user = User::factory()->create();
|
|
$to = cohCategory($user);
|
|
|
|
$transaction = Transaction::factory()->plaintext()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => cohCategory($user)->id,
|
|
'category_source' => CategorySource::Ai,
|
|
'ai_confidence' => 0.9,
|
|
'creditor_name' => null,
|
|
'debtor_name' => null,
|
|
'description' => 'Netflix subscription',
|
|
]);
|
|
|
|
$learned = app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
|
|
|
expect($learned)->not->toBeNull()
|
|
->and($learned->origin)->toBe(RuleOrigin::Correction)
|
|
->and($learned->rules_json)->toBe([
|
|
'and' => [
|
|
['in' => ['netflix', ['var' => 'description']]],
|
|
['in' => ['subscription', ['var' => 'description']]],
|
|
],
|
|
]);
|
|
|
|
$next = Transaction::factory()->plaintext()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => null,
|
|
'creditor_name' => null,
|
|
'debtor_name' => null,
|
|
'description' => 'NETFLIX SUBSCRIPTION 12,99',
|
|
]);
|
|
app(AutomationRuleService::class)->applyRules($next);
|
|
|
|
expect($next->refresh()->category_id)->toBe($to->id);
|
|
});
|
|
|
|
it('does not learn an over-broad description rule', function () {
|
|
config()->set('ai_suggestions.overbroad_fraction', 0.1);
|
|
|
|
$user = User::factory()->create();
|
|
$to = cohCategory($user);
|
|
|
|
foreach (range(1, 5) as $ignored) {
|
|
Transaction::factory()->plaintext()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => null,
|
|
'creditor_name' => null,
|
|
'debtor_name' => null,
|
|
'description' => 'Generic payment note',
|
|
]);
|
|
}
|
|
|
|
$transaction = Transaction::factory()->plaintext()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => cohCategory($user)->id,
|
|
'category_source' => CategorySource::Ai,
|
|
'ai_confidence' => 0.9,
|
|
'creditor_name' => null,
|
|
'debtor_name' => null,
|
|
'description' => 'Generic payment note',
|
|
]);
|
|
|
|
$learned = app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
|
|
|
expect($learned)->toBeNull()
|
|
->and(AutomationRule::query()->origin(RuleOrigin::Correction)->count())->toBe(0);
|
|
});
|
|
|
|
it('moves a merchant key to the new category when the user changes their mind', function () {
|
|
$user = User::factory()->create();
|
|
$first = cohCategory($user);
|
|
$second = cohCategory($user);
|
|
|
|
cohLearnRule($user, cohCategory($user)->id, 'Mercadona');
|
|
$matched = cohMatched($user, 'Mercadona');
|
|
$firstRule = app(CategoryOverrideHandler::class)->record($matched, $first->id);
|
|
|
|
$later = cohMerchantTxn($user, 'Mercadona');
|
|
app(AutomationRuleService::class)->applyRules($later);
|
|
$later->refresh();
|
|
|
|
expect($later->categorized_by_rule_id)->toBe($firstRule->id);
|
|
|
|
$secondRule = app(CategoryOverrideHandler::class)->record($later, $second->id);
|
|
|
|
expect($secondRule->action_category_id)->toBe($second->id)
|
|
->and($secondRule->rules_json)->toBe(['==' => [['var' => 'creditor_name'], 'mercadona']])
|
|
->and(AutomationRule::query()->find($firstRule->id))->toBeNull()
|
|
->and(CategoryCorrection::query()->count())->toBe(1);
|
|
});
|
|
|
|
it('out-ranks an ai rule when a direct ai label is corrected for the same merchant', function () {
|
|
$user = User::factory()->create();
|
|
$wrong = cohCategory($user);
|
|
$right = cohCategory($user);
|
|
|
|
// An ai rule already maps mercadona -> wrong (learned from an earlier txn).
|
|
$aiRule = cohLearnRule($user, $wrong->id, 'Mercadona');
|
|
|
|
// A different mercadona transaction was labeled DIRECTLY by the model: it
|
|
// carries no rule id, so the old self-heal would have left the ai rule intact.
|
|
$direct = cohMerchantTxn($user, 'Mercadona');
|
|
$direct->update([
|
|
'category_id' => $wrong->id,
|
|
'category_source' => CategorySource::Ai,
|
|
'ai_confidence' => 0.8,
|
|
]);
|
|
|
|
app(CategoryOverrideHandler::class)->record($direct, $right->id);
|
|
|
|
$next = cohMerchantTxn($user, 'Mercadona');
|
|
app(AutomationRuleService::class)->applyRules($next);
|
|
$next->refresh();
|
|
|
|
expect($next->category_id)->toBe($right->id)
|
|
->and(AutomationRule::query()->find($aiRule->id))->toBeNull();
|
|
});
|
|
|
|
it('self-heals but learns nothing when correcting to uncategorized', function () {
|
|
$user = User::factory()->create();
|
|
cohLearnRule($user, cohCategory($user)->id, 'Mercadona');
|
|
$matched = cohMatched($user, 'Mercadona');
|
|
$aiRuleId = $matched->categorized_by_rule_id;
|
|
|
|
$learned = app(CategoryOverrideHandler::class)->record($matched, null);
|
|
|
|
expect($learned)->toBeNull()
|
|
->and(AutomationRule::query()->find($aiRuleId))->toBeNull()
|
|
->and(AutomationRule::query()->origin(RuleOrigin::Correction)->count())->toBe(0);
|
|
});
|
|
|
|
it('learns a debtor_name rule when only the debtor is present', function () {
|
|
$user = User::factory()->create();
|
|
$to = cohCategory($user);
|
|
|
|
$transaction = Transaction::factory()->plaintext()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => cohCategory($user)->id,
|
|
'category_source' => CategorySource::Ai,
|
|
'ai_confidence' => 0.9,
|
|
'creditor_name' => null,
|
|
'debtor_name' => 'Juan Perez',
|
|
'description' => 'Bizum recibido',
|
|
]);
|
|
|
|
$learned = app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
|
|
|
expect($learned->rules_json)->toBe(['==' => [['var' => 'debtor_name'], 'juan perez']]);
|
|
|
|
$next = Transaction::factory()->plaintext()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => null,
|
|
'creditor_name' => null,
|
|
'debtor_name' => 'Juan Perez',
|
|
'description' => 'Bizum recibido de nuevo',
|
|
]);
|
|
app(AutomationRuleService::class)->applyRules($next);
|
|
|
|
expect($next->refresh()->category_id)->toBe($to->id);
|
|
});
|
|
|
|
it('learns a single-token description rule as a bare contains clause', function () {
|
|
$user = User::factory()->create();
|
|
$to = cohCategory($user);
|
|
|
|
$transaction = Transaction::factory()->plaintext()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => cohCategory($user)->id,
|
|
'category_source' => CategorySource::Ai,
|
|
'ai_confidence' => 0.9,
|
|
'creditor_name' => null,
|
|
'debtor_name' => null,
|
|
'description' => 'Spotify',
|
|
]);
|
|
|
|
$learned = app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
|
|
|
expect($learned->rules_json)->toBe(['in' => ['spotify', ['var' => 'description']]]);
|
|
});
|
|
|
|
it('learns nothing from an encrypted-description transaction without a merchant', function () {
|
|
$user = User::factory()->create();
|
|
$to = cohCategory($user);
|
|
|
|
$transaction = Transaction::factory()->create([
|
|
'user_id' => $user->id,
|
|
'category_id' => cohCategory($user)->id,
|
|
'category_source' => CategorySource::Ai,
|
|
'ai_confidence' => 0.9,
|
|
'creditor_name' => null,
|
|
'debtor_name' => null,
|
|
]);
|
|
|
|
expect($transaction->description_iv)->not->toBeNull();
|
|
|
|
$learned = app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
|
|
|
expect($learned)->toBeNull()
|
|
->and(AutomationRule::query()->origin(RuleOrigin::Correction)->count())->toBe(0);
|
|
});
|