feat(ai): auto-categorize transactions with AI (behind flag) (#535)

## What

Auto-categorizes transactions with AI (Gemini) for **pro +
AI-consented** users when no automation rule already matched. Ships the
full backend **behind a Pennant flag, off by default**, so it's
mergeable and testable in isolation; the UI is a deliberate follow-up.

## Why / cost

Prod check first: ~20k txns/month, **~52% of pro-user transactions are
uncategorized** after rules. At Gemini Flash-Lite rates the cost is a
**rounding error** — ~$0.13–$0.75/month for all pro users, single-digit
dollars even on full Flash. So the model is chosen for accuracy, not
price; the real constraints are trust, accuracy and privacy.

## How it works

**Two tiers** (every transaction is covered, rules are an optimization
on top):
- **Tier 1 – label** — a queued listener runs *after* the synchronous
rules; if still uncategorized and the user is eligible, the model picks
a **leaf** category (referenced by numeric index, never a UUID, so it
can't hallucinate one). Auto-applied only above the **label bar**
(`0.7`); below → left blank, no nag. Tagged `category_source = ai` +
`ai_confidence`, fully reversible.
- **Tier 2 – learn** — above the higher **rule bar** (`0.85`) *and* a
clean merchant key *and* the model flags the merchant unambiguous → the
merchant is appended to a single **ai-owned** automation rule for that
category (OR'd conditions, not rule-sprawl), so future transactions
match for free and consistently. AI rules sit at the lowest priority;
**user-owned rules are never touched**.

**Self-heal + signal** — when a user overrides an AI category, a
`category_correction` is logged (calibration signal, bucketable by
confidence) and the offending merchant condition is dropped from the ai
rule (deleted if empty). User rules and manual categories are untouched.

**Safety** — config kill switch + pro + active consent + gradual Pennant
rollout. Dedicated `ai` queue so Gemini never blocks bank syncs.
Encrypted (client-side) transactions are never sent.

**Backfill** — `ai:categorize-backfill {user}`, explicit opt-in,
batched, learns rules as it goes.

## Data model

- `transactions`: `category_source`, `ai_confidence`,
`categorized_by_rule_id`
- `automation_rules`: `origin` (`user`/`ai`)
- new `category_corrections` table

## Screenshots

<img width="921" height="384" alt="image"
src="https://github.com/user-attachments/assets/f04c2a03-b39e-4a3d-81eb-ecf26eaefb83"
/>
This commit is contained in:
Víctor Falcón 2026-06-15 16:35:20 +02:00 committed by GitHub
parent 1d4bcd5082
commit 8013a0b6f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 2437 additions and 24 deletions

View File

@ -0,0 +1,66 @@
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
/**
* Assigns each given transaction to one of the user's existing leaf categories.
* The agent only ever sees merchant/description signals and the user's own
* category list never full account context. Categories are referenced by a
* numeric "index" (not their id) so the model cannot hallucinate an identifier,
* and the caller maps the index back to a real category.
*/
class TransactionCategorizationAgent implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return <<<'PROMPT'
You categorize a personal-finance app user's bank transactions. You are given a
JSON object with:
- "transactions": the transactions to categorize. Each has a "ref" (echo it back
verbatim), a "text" (the bank description), an "amount", a "direction"
(outflow = money spent, inflow = money received) and optional "creditor_name"
/ "debtor_name" counterparties.
- "categories": the user's existing LEAF categories. Each has an "index", a "path"
(parent > child), a "type" and a "direction". You may ONLY choose from these.
For each transaction you can confidently place, return one result:
- "ref": echo the transaction's "ref".
- "category_index": the "index" of the best-fitting category. An outflow MUST map
to a spending category, an inflow to an income category. If no category fits,
OMIT this field (do not guess).
- "confidence": 0.01.0, how sure you are of this category for THIS transaction.
- "merchant_unambiguous": true only if the counterparty/merchant reliably maps to
this ONE category for every future transaction (e.g. "Netflix" Subscriptions,
"Mercadona" Groceries). false when the right category depends on the specific
purchase (e.g. "Amazon", "PayPal", a generic bank transfer, an ATM withdrawal).
Let "confidence" honestly reflect your certainty the app filters out low-confidence
results itself. Never invent a category that is not in the provided list.
PROMPT;
}
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'results' => $schema->array()->items(
$schema->object(fn (JsonSchema $schema): array => [
'ref' => $schema->string()->required(),
'category_index' => $schema->integer(),
'confidence' => $schema->number()->required(),
'merchant_unambiguous' => $schema->boolean()->required(),
])
)->required(),
];
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands;
use App\Enums\RuleOrigin;
use App\Models\AutomationRule;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use App\Services\Ai\AiCategorizer;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
class CategorizeBackfillCommand extends Command
{
protected $signature = 'ai:categorize-backfill {user : User id or email}';
protected $description = 'Categorize a user\'s existing uncategorized transactions with AI (explicit, opt-in backfill)';
public function handle(AiCategorizationGate $gate, AiCategorizer $categorizer): int
{
$user = $this->resolveUser((string) $this->argument('user'));
if ($user === null) {
$this->error('User not found.');
return self::FAILURE;
}
if (! $gate->allowsBackfill($user)) {
$this->warn('User is not eligible (needs the feature enabled, a pro plan and active AI consent).');
return self::FAILURE;
}
$pendingIds = Transaction::query()
->where('user_id', $user->id)
->whereNull('category_id')
->whereNull('description_iv')
->pluck('id');
if ($pendingIds->isEmpty()) {
$this->info('No uncategorized transactions to backfill.');
return self::SUCCESS;
}
$rulesBefore = $this->aiRuleCount($user);
$applied = 0;
$batchSize = max(1, (int) config('ai_categorization.group_batch_size'));
// Chunk a fixed snapshot of ids so transactions left blank (below the
// confidence bar) are never re-processed on a later iteration.
$this->withProgressBar(
$pendingIds->chunk($batchSize),
function ($chunkIds) use ($user, $categorizer, &$applied): void {
$chunk = Transaction::query()->whereIn('id', $chunkIds->all())->get();
$outcomes = $categorizer->run($user, new Collection($chunk->all()));
$applied += count(array_filter($outcomes, fn ($outcome): bool => $outcome->applied));
},
);
$this->newLine(2);
$this->components->info(sprintf(
'Backfill complete: %d transaction(s) categorized, %d AI rule(s) learned.',
$applied,
$this->aiRuleCount($user) - $rulesBefore,
));
return self::SUCCESS;
}
private function aiRuleCount(User $user): int
{
return AutomationRule::query()
->where('user_id', $user->id)
->origin(RuleOrigin::Ai)
->count();
}
private function resolveUser(string $identifier): ?User
{
return User::query()->where('email', $identifier)->first()
?? User::query()->find($identifier);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Enums;
enum CategorySource: string
{
case Manual = 'manual';
case Rule = 'rule';
case Ai = 'ai';
case Bank = 'bank';
public function label(): string
{
return match ($this) {
self::Manual => 'Manual',
self::Rule => 'Rule',
self::Ai => 'AI',
self::Bank => 'Bank',
};
}
public function isAi(): bool
{
return $this === self::Ai;
}
}

22
app/Enums/RuleOrigin.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace App\Enums;
enum RuleOrigin: string
{
case User = 'user';
case Ai = 'ai';
public function label(): string
{
return match ($this) {
self::User => 'User',
self::Ai => 'AI',
};
}
public function isAi(): bool
{
return $this === self::Ai;
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Features;
use App\Models\User;
use Illuminate\Support\Carbon;
/**
* Gates AI auto-categorization of transactions. Rolled out to users who signed
* up after `ai_categorization.rollout_after`; combined with the pro plan and
* AI consent checks in AiCategorizationGate.
*
* @api
*/
class AiCategorization
{
/**
* Resolve the feature's initial value.
*/
public function resolve(?User $user): bool
{
if ($user === null) {
return false;
}
$rolloutAfter = config('ai_categorization.rollout_after');
if (blank($rolloutAfter)) {
return false;
}
return $user->created_at !== null
&& $user->created_at->gt(Carbon::parse($rolloutAfter));
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Enums\CategorySource;
use App\Http\Requests\BulkUpdateTransactionsRequest;
use App\Http\Requests\IndexTransactionRequest;
use App\Http\Requests\StoreTransactionRequest;
@ -12,6 +13,7 @@ use App\Models\Bank;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Services\Ai\CategoryOverrideHandler;
use App\Services\ManualBalanceAdjuster;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
@ -45,12 +47,13 @@ class TransactionController extends Controller
'label_ids' => $validated['label_ids'] ?? null,
'creditor_name' => $validated['creditor_name'] ?? null,
'debtor_name' => $validated['debtor_name'] ?? null,
'category_source' => $validated['category_source'] ?? null,
'search' => $validated['search'] ?? null,
], fn ($value) => $value !== null);
$query = Transaction::query()
->where('user_id', $user->id)
->with(['account.bank', 'category', 'labels'])
->with(['account.bank', 'category', 'labels', 'categorizedByRule:id,origin'])
->applyFilters($filters);
$nullableSortColumns = ['creditor_name', 'debtor_name'];
@ -69,7 +72,10 @@ class TransactionController extends Controller
->cursorPaginate($perPage)
->withQueryString();
$transactions->getCollection()->each(fn (Transaction $transaction) => $transaction->makeHidden(['creditor_name_sort', 'debtor_name_sort']));
$transactions->getCollection()->each(function (Transaction $transaction): void {
$transaction->makeHidden(['creditor_name_sort', 'debtor_name_sort'])
->append('ai_categorized');
});
$appliedFilters = [
'date_from' => $validated['date_from'] ?? null,
@ -81,6 +87,7 @@ class TransactionController extends Controller
'label_ids' => $validated['label_ids'] ?? [],
'creditor_name' => $validated['creditor_name'] ?? '',
'debtor_name' => $validated['debtor_name'] ?? '',
'category_source' => $validated['category_source'] ?? null,
'search' => $validated['search'] ?? '',
'sort' => $sortParam,
];
@ -201,6 +208,20 @@ class TransactionController extends Controller
$hasLabelUpdate = $request->has('label_ids');
unset($data['label_ids']);
// A user-set category overrides any AI assignment: log the correction,
// self-heal the ai rule, and reset the provenance to manual.
if ($request->has('category_id')) {
$newCategoryId = $data['category_id'] ?? null;
if ($newCategoryId !== $transaction->category_id) {
app(CategoryOverrideHandler::class)->record($transaction, $newCategoryId);
$data['category_source'] = $newCategoryId === null ? null : CategorySource::Manual->value;
$data['ai_confidence'] = null;
$data['categorized_by_rule_id'] = null;
}
}
// Update attributes directly without firing events yet
if (! empty($data)) {
$transaction->fill($data);
@ -269,7 +290,16 @@ class TransactionController extends Controller
$updateData = [];
if ($request->has('category_id')) {
$updateData['category_id'] = $request->input('category_id');
$newCategoryId = $request->input('category_id');
foreach ($transactions as $transaction) {
app(CategoryOverrideHandler::class)->record($transaction, $newCategoryId);
}
$updateData['category_id'] = $newCategoryId;
$updateData['category_source'] = $newCategoryId === null ? null : CategorySource::Manual->value;
$updateData['ai_confidence'] = null;
$updateData['categorized_by_rule_id'] = null;
}
if ($request->has('notes')) {
$updateData['notes'] = $request->input('notes');

View File

@ -41,6 +41,7 @@ class IndexTransactionRequest extends FormRequest
'label_ids.*' => ['string', 'uuid'],
'creditor_name' => ['nullable', 'string', 'max:255'],
'debtor_name' => ['nullable', 'string', 'max:255'],
'category_source' => ['nullable', 'string', 'in:manual,rule,ai,bank'],
'search' => ['nullable', 'string', 'max:200'],
'sort' => ['nullable', 'string', 'in:transaction_date,-transaction_date,amount,-amount,description,-description,creditor_name,-creditor_name,debtor_name,-debtor_name'],
'cursor' => ['nullable', 'string'],

View File

@ -0,0 +1,54 @@
<?php
namespace App\Listeners;
use App\Events\TransactionCreated;
use App\Services\Ai\AiCategorizationGate;
use App\Services\Ai\AiCategorizer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Collection;
/**
* Real-time tier of AI auto-categorization. Runs AFTER the synchronous automation
* rules (it is queued, so it executes once the transaction is persisted) and only
* acts when the transaction is still uncategorized and the user is eligible.
*
* Queued on its own connection/queue so a backlog never delays bank syncs, and a
* Gemini outage can't block the import pipeline.
*/
class CategorizeTransactionWithAi implements ShouldQueue
{
use InteractsWithQueue;
public function __construct(
private readonly AiCategorizationGate $gate,
private readonly AiCategorizer $categorizer,
) {}
public function viaQueue(): string
{
return (string) config('ai_categorization.queue');
}
public function handle(TransactionCreated $event): void
{
$transaction = $event->transaction;
if ($transaction->category_id !== null) {
return;
}
if ($transaction->description_iv !== null) {
return;
}
$user = $transaction->user;
if ($user === null || ! $this->gate->allows($user)) {
return;
}
$this->categorizer->run($user, new Collection([$transaction]));
}
}

View File

@ -2,7 +2,9 @@
namespace App\Models;
use App\Enums\RuleOrigin;
use Database\Factories\AutomationRuleFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -10,6 +12,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property array<string, mixed> $rules_json
* @property RuleOrigin $origin
*/
class AutomationRule extends Model
{
/** @use HasFactory<AutomationRuleFactory> */
@ -19,6 +25,7 @@ class AutomationRule extends Model
'user_id',
'title',
'priority',
'origin',
'rules_json',
'action_category_id',
'action_note',
@ -30,9 +37,19 @@ class AutomationRule extends Model
return [
'rules_json' => 'array',
'priority' => 'integer',
'origin' => RuleOrigin::class,
];
}
/**
* @param Builder<AutomationRule> $query
* @return Builder<AutomationRule>
*/
public function scopeOrigin(Builder $query, RuleOrigin $origin): Builder
{
return $query->where('origin', $origin);
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{

View File

@ -0,0 +1,50 @@
<?php
namespace App\Models;
use App\Enums\CategorySource;
use Database\Factories\CategoryCorrectionFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Records every time a user overrides a category that was assigned by AI (either
* directly or via an AI-owned rule). The signal is used to measure accuracy per
* confidence bucket and to recalibrate the auto-apply thresholds.
*/
class CategoryCorrection extends Model
{
/** @use HasFactory<CategoryCorrectionFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'user_id',
'transaction_id',
'from_category_id',
'to_category_id',
'source',
'confidence',
];
protected function casts(): array
{
return [
'source' => CategorySource::class,
'confidence' => 'float',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<Transaction, $this> */
public function transaction(): BelongsTo
{
return $this->belongsTo(Transaction::class);
}
}

View File

@ -2,6 +2,8 @@
namespace App\Models;
use App\Enums\CategorySource;
use App\Enums\RuleOrigin;
use App\Enums\TransactionSource;
use App\Events\TransactionCreated;
use App\Events\TransactionDeleted;
@ -10,6 +12,7 @@ use App\Services\CategoryTree;
use Carbon\Carbon;
use Database\Factories\TransactionFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -21,6 +24,9 @@ use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property Carbon $transaction_date
* @property int|float $total_amount
* @property ?CategorySource $category_source
* @property ?float $ai_confidence
* @property ?string $categorized_by_rule_id
*/
class Transaction extends Model
{
@ -38,6 +44,9 @@ class Transaction extends Model
'user_id',
'account_id',
'category_id',
'category_source',
'ai_confidence',
'categorized_by_rule_id',
'description',
'description_iv',
'original_description',
@ -65,6 +74,7 @@ class Transaction extends Model
'external_transaction_id',
'dedup_fingerprint',
'raw_data',
'categorized_by_rule_id',
'deleted_at',
];
@ -74,6 +84,8 @@ class Transaction extends Model
'transaction_date' => 'date:Y-m-d',
'amount' => 'integer',
'source' => TransactionSource::class,
'category_source' => CategorySource::class,
'ai_confidence' => 'float',
'raw_data' => 'array',
];
}
@ -96,6 +108,35 @@ class Transaction extends Model
return $this->belongsTo(Category::class);
}
/** @return BelongsTo<AutomationRule, $this> */
public function categorizedByRule(): BelongsTo
{
return $this->belongsTo(AutomationRule::class, 'categorized_by_rule_id');
}
/**
* Whether AI assigned this transaction's category either directly or via an
* AI-owned rule. Not appended by default; surfaces opt in (e.g. the index
* controller eager-loads `categorizedByRule:id,origin` and appends this) so
* the rule-origin check never triggers a lazy load.
*
* @return Attribute<bool, never>
*/
protected function aiCategorized(): Attribute
{
return Attribute::make(get: function (): bool {
if ($this->category_source === CategorySource::Ai) {
return true;
}
if (! $this->relationLoaded('categorizedByRule')) {
return false;
}
return $this->categorizedByRule?->origin === RuleOrigin::Ai;
});
}
/** @return BelongsToMany<Label, $this, LabelTransaction, 'pivot'> */
public function labels(): BelongsToMany
{
@ -178,6 +219,10 @@ class Transaction extends Model
$query->whereIn('account_id', $filters['account_ids']);
}
if (! empty($filters['category_source'])) {
$query->where('category_source', $filters['category_source']);
}
if (! empty($filters['creditor_name'])) {
$term = '%'.$filters['creditor_name'].'%';
$query->where('creditor_name', 'LIKE', $term);

View File

@ -9,6 +9,7 @@ use App\Events\TransactionUpdated;
use App\Http\Responses\RegisterResponse;
use App\Listeners\ApplyAutomationRules;
use App\Listeners\AssignTransactionToBudget;
use App\Listeners\CategorizeTransactionWithAi;
use App\Listeners\PostStripeEventToDiscord;
use App\Listeners\UnassignTransactionFromBudget;
use App\Listeners\UpdateLastLoggedInAt;
@ -60,6 +61,7 @@ class AppServiceProvider extends ServiceProvider
{
Event::listen(TransactionCreated::class, ApplyAutomationRules::class);
Event::listen(TransactionCreated::class, AssignTransactionToBudget::class);
Event::listen(TransactionCreated::class, CategorizeTransactionWithAi::class);
Event::listen(TransactionUpdated::class, AssignTransactionToBudget::class);
Event::listen(TransactionDeleted::class, UnassignTransactionFromBudget::class);
Event::listen(WebhookReceived::class, PostStripeEventToDiscord::class);

View File

@ -0,0 +1,43 @@
<?php
namespace App\Services\Ai;
use App\Features\AiCategorization;
use App\Models\User;
use Laravel\Pennant\Feature;
/**
* The eligibility gate for AI auto-categorization: a hard config kill switch, a
* pro subscription, an active (current-version) AI consent, and the per-user
* Pennant rollout flag. All four must hold before any transaction is sent.
*/
class AiCategorizationGate
{
public function allows(User $user): bool
{
if (! (bool) config('ai_categorization.enabled')) {
return false;
}
if (! $user->hasProPlan()) {
return false;
}
if (! $user->hasActiveAiConsent()) {
return false;
}
return Feature::for($user)->active(AiCategorization::class);
}
/**
* Backfill is an explicit, user-initiated action, so it requires the kill
* switch, a pro plan and active consent but not the gradual rollout flag.
*/
public function allowsBackfill(User $user): bool
{
return (bool) config('ai_categorization.enabled')
&& $user->hasProPlan()
&& $user->hasActiveAiConsent();
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Services\Ai;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Collection;
/**
* Orchestrates both tiers of AI auto-categorization for a set of transactions:
* label each one (tier 1) and then learn a rule from every confident,
* unambiguous result (tier 2). Shared by the real-time listener and the backfill
* command.
*/
class AiCategorizer
{
public function __construct(
private readonly CategorizeTransactions $categorizer,
private readonly AiRuleLearner $learner,
) {}
/**
* @param Collection<int, Transaction> $transactions
* @return list<CategorizationOutcome>
*/
public function run(User $user, Collection $transactions): array
{
$outcomes = $this->categorizer->forTransactions($user, $transactions);
foreach ($outcomes as $outcome) {
$this->learner->learn($outcome);
}
return $outcomes;
}
}

View File

@ -0,0 +1,217 @@
<?php
namespace App\Services\Ai;
use App\Enums\RuleOrigin;
use App\Models\AutomationRule;
use App\Models\Category;
use App\Models\Transaction;
use Illuminate\Support\Str;
/**
* Tier 2 of AI auto-categorization: turn a confident, unambiguous, merchant-keyed
* categorization into a deterministic rule so every future transaction from the
* same merchant is categorized for free and consistently no repeat model call.
*
* To avoid rule sprawl, all of a user's AI-categorizations for one category live
* in a SINGLE ai-owned rule whose conditions are OR'd together; a new merchant is
* appended to that rule rather than spawning another. AI never touches a rule the
* user created or edited (origin = user). New ai rules sit at the lowest priority
* (highest number) so a user's own rules always win.
*/
class AiRuleLearner
{
public function learn(CategorizationOutcome $outcome): ?AutomationRule
{
if (! $outcome->merchantUnambiguous) {
return null;
}
if ($outcome->confidence < (float) config('ai_categorization.rule_confidence')) {
return null;
}
$key = $this->merchantKey($outcome->transaction);
if ($key === null) {
return null;
}
[$field, $token] = $key;
$rule = $this->existingAiRule($outcome->transaction->user_id, $outcome->categoryId)
?? $this->createAiRule($outcome->transaction->user_id, $outcome->categoryId);
$this->appendCondition($rule, $field, $token);
$outcome->transaction->categorized_by_rule_id = $rule->id;
$outcome->transaction->saveQuietly();
return $rule;
}
/**
* @return array{0: string, 1: string}|null [field, token]
*/
private function merchantKey(Transaction $transaction): ?array
{
foreach (['creditor_name', 'debtor_name'] as $field) {
$value = $this->normalize((string) ($transaction->{$field} ?? ''));
if ($value !== '') {
return [$field, $value];
}
}
return null;
}
private function existingAiRule(string $userId, string $categoryId): ?AutomationRule
{
return AutomationRule::query()
->where('user_id', $userId)
->where('action_category_id', $categoryId)
->origin(RuleOrigin::Ai)
->first();
}
private function createAiRule(string $userId, string $categoryId): AutomationRule
{
$priority = (int) AutomationRule::query()->where('user_id', $userId)->max('priority');
return AutomationRule::create([
'user_id' => $userId,
'title' => $this->title($categoryId, []),
'priority' => $priority + 1,
'origin' => RuleOrigin::Ai,
'rules_json' => [],
'action_category_id' => $categoryId,
]);
}
/**
* Self-heal after a user corrects a transaction this ai-owned rule labeled:
* drop the merchant condition(s) matching the transaction so the rule stops
* forcing the wrong category on future transactions from that merchant. The
* rule is deleted when no condition remains.
*/
public function forget(AutomationRule $rule, Transaction $transaction): void
{
$tokens = [];
foreach (['creditor_name', 'debtor_name'] as $field) {
$value = $this->normalize((string) ($transaction->{$field} ?? ''));
if ($value !== '') {
$tokens[$value] = true;
}
}
if ($tokens === []) {
return;
}
$clauses = $this->clauses($rule->rules_json);
$remaining = array_values(array_filter($clauses, function (array $clause) use ($tokens): bool {
$token = $clause['=='][1] ?? null;
return ! (is_string($token) && isset($tokens[$token]));
}));
if (count($remaining) === count($clauses)) {
return;
}
if ($remaining === []) {
$rule->delete();
return;
}
$rule->rules_json = count($remaining) === 1 ? $remaining[0] : ['or' => $remaining];
$rule->title = $this->title((string) $rule->action_category_id, $this->tokens($remaining));
$rule->save();
}
private function appendCondition(AutomationRule $rule, string $field, string $token): void
{
$clause = ['==' => [['var' => $field], $token]];
$clauses = $this->clauses($rule->rules_json);
foreach ($clauses as $existing) {
if ($existing == $clause) {
return;
}
}
$clauses[] = $clause;
$rule->rules_json = count($clauses) === 1 ? $clauses[0] : ['or' => $clauses];
$rule->title = $this->title((string) $rule->action_category_id, $this->tokens($clauses));
$rule->save();
}
/**
* The individual condition clauses of a rule, normalised to a flat list
* regardless of whether it is a single clause or an OR of several.
*
* @param mixed $rulesJson
* @return list<array<string, mixed>>
*/
private function clauses($rulesJson): array
{
if (! is_array($rulesJson) || $rulesJson === []) {
return [];
}
if (isset($rulesJson['or']) && is_array($rulesJson['or'])) {
return array_values($rulesJson['or']);
}
return [$rulesJson];
}
/**
* @param list<array<string, mixed>> $clauses
* @return list<string>
*/
private function tokens(array $clauses): array
{
$tokens = [];
foreach ($clauses as $clause) {
$token = $clause['=='][1] ?? null;
if (is_string($token)) {
$tokens[] = $token;
}
}
return $tokens;
}
/**
* @param list<string> $tokens
*/
private function title(string $categoryId, array $tokens): string
{
$categoryName = Category::query()->whereKey($categoryId)->value('name') ?? '';
if ($tokens === []) {
return trim($categoryName.' (AI)');
}
$label = implode(', ', array_map(fn (string $token): string => Str::title($token), array_slice($tokens, 0, 3)));
if (count($tokens) > 3) {
$label .= '…';
}
return trim($label.' → '.$categoryName);
}
private function normalize(string $value): string
{
return trim(preg_replace('/\s+/', ' ', mb_strtolower($value)) ?? '');
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Services\Ai;
use App\Models\Transaction;
/**
* The result of asking the model to categorize a single transaction: the chosen
* category (already resolved to a real id), the model's confidence, whether the
* merchant is safe to generalise into a rule, and whether the label was actually
* auto-applied (i.e. it cleared the label confidence bar).
*/
final readonly class CategorizationOutcome
{
public function __construct(
public Transaction $transaction,
public string $categoryId,
public float $confidence,
public bool $merchantUnambiguous,
public bool $applied,
) {}
}

View File

@ -0,0 +1,158 @@
<?php
namespace App\Services\Ai;
use App\Ai\Agents\TransactionCategorizationAgent;
use App\Enums\CategorySource;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Collection;
use Laravel\Ai\Enums\Lab;
use Throwable;
/**
* Tier 1 of AI auto-categorization: ask the model to assign each transaction to
* one of the user's leaf categories and auto-apply the label when it clears the
* label confidence bar. Returns an outcome per transaction the model placed so
* the caller can drive tier 2 (rule learning) off the high-confidence ones.
*/
class CategorizeTransactions
{
/**
* @param Collection<int, Transaction> $transactions
* @return list<CategorizationOutcome>
*/
public function forTransactions(User $user, Collection $transactions): array
{
$transactions = $transactions->filter(
fn (Transaction $transaction): bool => $transaction->description_iv === null,
)->values();
if ($transactions->isEmpty()) {
return [];
}
$catalog = CategoryCatalog::forUser($user);
if ($catalog->isEmpty()) {
return [];
}
$byRef = $transactions->keyBy(fn (Transaction $transaction): string => $transaction->id);
$results = $this->resolve($transactions, $catalog);
$labelBar = (float) config('ai_categorization.label_confidence');
$outcomes = [];
foreach ($results as $result) {
$transaction = $byRef->get((string) ($result['ref'] ?? ''));
if ($transaction === null) {
continue;
}
$categoryId = $catalog->categoryIdForIndex(
isset($result['category_index']) ? (int) $result['category_index'] : null,
);
if ($categoryId === null) {
continue;
}
$confidence = (float) ($result['confidence'] ?? 0.0);
$applied = $confidence >= $labelBar;
if ($applied) {
$this->applyLabel($transaction, $categoryId, $confidence);
}
$outcomes[] = new CategorizationOutcome(
transaction: $transaction,
categoryId: $categoryId,
confidence: $confidence,
merchantUnambiguous: (bool) ($result['merchant_unambiguous'] ?? false),
applied: $applied,
);
}
return $outcomes;
}
private function applyLabel(Transaction $transaction, string $categoryId, float $confidence): void
{
$transaction->category_id = $categoryId;
$transaction->category_source = CategorySource::Ai;
$transaction->ai_confidence = $confidence;
$transaction->save();
}
/**
* Send the transactions to the model in bounded chunks and merge the
* results. A chunk that fails after a retry is dropped without discarding
* the chunks that succeeded.
*
* @param Collection<int, Transaction> $transactions
* @return list<array<string, mixed>>
*/
private function resolve(Collection $transactions, CategoryCatalog $catalog): array
{
$batchSize = max(1, (int) config('ai_categorization.group_batch_size'));
$results = [];
foreach ($transactions->chunk($batchSize) as $chunk) {
try {
foreach ($this->resolveChunkWithRetry($chunk, $catalog) as $result) {
$results[] = $result;
}
} catch (Throwable $exception) {
report($exception);
}
}
return $results;
}
/**
* @param Collection<int, Transaction> $chunk
* @return list<array<string, mixed>>
*/
private function resolveChunkWithRetry(Collection $chunk, CategoryCatalog $catalog): array
{
try {
return $this->resolveChunk($chunk, $catalog);
} catch (Throwable) {
return $this->resolveChunk($chunk, $catalog);
}
}
/**
* @param Collection<int, Transaction> $chunk
* @return list<array<string, mixed>>
*/
private function resolveChunk(Collection $chunk, CategoryCatalog $catalog): array
{
$items = $chunk->map(fn (Transaction $transaction): array => [
'ref' => $transaction->id,
'text' => (string) $transaction->description,
'amount' => $transaction->amount / 100,
'direction' => $transaction->amount < 0 ? 'outflow' : 'inflow',
'creditor_name' => $transaction->creditor_name,
'debtor_name' => $transaction->debtor_name,
])->values()->all();
$payload = json_encode([
'transactions' => $items,
'categories' => $catalog->options(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$response = (new TransactionCategorizationAgent)->prompt(
$payload,
provider: Lab::Gemini,
model: (string) config('ai_categorization.model'),
);
$results = $response['results'] ?? [];
return is_array($results) ? array_values($results) : [];
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Services\Ai;
use App\Models\Category;
use App\Models\User;
use Illuminate\Support\Collection;
/**
* The closed, indexed list of a user's LEAF categories handed to the
* categorization agent. Categories are referenced by a stable integer index
* rather than their UUID so the model cannot hallucinate an identifier; the
* catalog maps a returned index back to a real category id.
*/
class CategoryCatalog
{
/**
* @param array<int, string> $idByIndex index => category id
* @param list<array{index: int, path: string, type: string, direction: string}> $options
*/
private function __construct(
private readonly array $idByIndex,
private readonly array $options,
) {}
public static function forUser(User $user): self
{
$categories = Category::query()
->where('user_id', $user->id)
->get();
$byId = $categories->keyBy('id');
$parentIds = $categories->pluck('parent_id')->filter()->unique()->flip();
$idByIndex = [];
$options = [];
$index = 0;
foreach ($categories as $category) {
if ($parentIds->has($category->id)) {
continue;
}
$idByIndex[$index] = $category->id;
$options[] = [
'index' => $index,
'path' => self::path($category, $byId),
'type' => $category->type->value,
'direction' => $category->cashflow_direction->value,
];
$index++;
}
return new self($idByIndex, $options);
}
/**
* @return list<array{index: int, path: string, type: string, direction: string}>
*/
public function options(): array
{
return $this->options;
}
public function isEmpty(): bool
{
return $this->options === [];
}
public function categoryIdForIndex(?int $index): ?string
{
if ($index === null) {
return null;
}
return $this->idByIndex[$index] ?? null;
}
/**
* @param Collection<string, Category> $byId
*/
private static function path(Category $category, Collection $byId): string
{
$parts = [$category->name];
$current = $category;
$guard = 0;
while ($current->parent_id !== null
&& $byId->has($current->parent_id)
&& $guard++ < Category::MAX_DEPTH) {
$current = $byId->get($current->parent_id);
array_unshift($parts, $current->name);
}
return implode(' > ', $parts);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Services\Ai;
use App\Enums\CategorySource;
use App\Enums\RuleOrigin;
use App\Models\AutomationRule;
use App\Models\CategoryCorrection;
use App\Models\Transaction;
/**
* Runs when a user overrides a transaction's category. If the category being
* replaced was assigned by AI directly, or via an ai-owned rule it records a
* correction (the calibration signal) and self-heals the rule so the same wrong
* category is not forced on future transactions from that merchant. User-owned
* rules and manual categories are never touched.
*
* Must be called BEFORE the new category is written, while the transaction still
* holds its previous categorization.
*/
class CategoryOverrideHandler
{
public function __construct(private readonly AiRuleLearner $learner) {}
public function record(Transaction $transaction, ?string $newCategoryId): void
{
if ($newCategoryId === $transaction->category_id) {
return;
}
$rule = $transaction->categorized_by_rule_id !== null
? AutomationRule::query()->find($transaction->categorized_by_rule_id)
: null;
$aiRule = $rule !== null && $rule->origin === RuleOrigin::Ai;
$aiDriven = $transaction->category_source === CategorySource::Ai || $aiRule;
if (! $aiDriven) {
return;
}
CategoryCorrection::create([
'user_id' => $transaction->user_id,
'transaction_id' => $transaction->id,
'from_category_id' => $transaction->category_id,
'to_category_id' => $newCategoryId,
'source' => $transaction->category_source ?? CategorySource::Rule,
'confidence' => $transaction->ai_confidence,
]);
if ($aiRule) {
$this->learner->forget($rule, $transaction);
}
}
}

View File

@ -2,6 +2,7 @@
namespace App\Services;
use App\Enums\CategorySource;
use App\Models\AutomationRule;
use App\Models\LabelTransaction;
use App\Models\Transaction;
@ -84,6 +85,8 @@ class AutomationRuleService
->whereIn('id', $categoryTransactionIds)
->update([
'category_id' => $rule->action_category_id,
'category_source' => CategorySource::Rule->value,
'categorized_by_rule_id' => $rule->id,
'updated_at' => now(),
]);
@ -276,6 +279,8 @@ class AutomationRuleService
if ($rule->action_category_id !== null
&& $transaction->category_id !== $rule->action_category_id) {
$transaction->category_id = $rule->action_category_id;
$transaction->category_source = CategorySource::Rule;
$transaction->categorized_by_rule_id = $rule->id;
$changed = true;
}

View File

@ -0,0 +1,86 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Model
|--------------------------------------------------------------------------
|
| The Gemini model used to categorize transactions. Cost is negligible at
| any tier for this task, so the model is chosen for accuracy, not price.
| Kept env-overridable so it can be swapped without a deploy.
|
*/
'model' => env('AI_CATEGORIZATION_MODEL', 'gemini-flash-latest'),
/*
|--------------------------------------------------------------------------
| Master switch
|--------------------------------------------------------------------------
|
| A hard kill switch independent of the per-user Pennant flag. When false,
| no transaction is ever sent for AI categorization, regardless of rollout.
|
*/
'enabled' => (bool) env('AI_CATEGORIZATION_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Rollout cohort
|--------------------------------------------------------------------------
|
| The AiCategorization Pennant feature resolves to active for users created
| strictly after this timestamp (parsed in the app timezone). This rolls the
| feature out to new signups only. Set to null to deactivate the cohort
| (the flag can still be activated per-user via Pennant directly).
|
*/
'rollout_after' => env('AI_CATEGORIZATION_ROLLOUT_AFTER', '2026-06-13 21:00:00'),
/*
|--------------------------------------------------------------------------
| Confidence bars
|--------------------------------------------------------------------------
|
| Two thresholds. "label_confidence" is the minimum confidence to auto-apply
| a category to a single transaction. "rule_confidence" is the higher bar a
| categorization must clear before it is generalised into an automation rule
| (a rule mislabels ALL future matches, so it must be more certain). Below
| "label_confidence" the transaction is left uncategorized.
|
*/
'label_confidence' => (float) env('AI_CATEGORIZATION_LABEL_CONFIDENCE', 0.7),
'rule_confidence' => (float) env('AI_CATEGORIZATION_RULE_CONFIDENCE', 0.85),
/*
|--------------------------------------------------------------------------
| Backfill batching
|--------------------------------------------------------------------------
|
| "group_batch_size" splits aggregated merchant groups into per-request
| chunks during a backfill run: a large single payload makes the model
| under-enumerate, so we send reliable-size chunks and merge the results.
|
*/
'group_batch_size' => (int) env('AI_CATEGORIZATION_GROUP_BATCH_SIZE', 50),
/*
|--------------------------------------------------------------------------
| Queue
|--------------------------------------------------------------------------
|
| The queue the real-time categorization job runs on. Kept separate from the
| default queue so a backlog of categorization jobs never delays bank syncs.
|
*/
'queue' => env('AI_CATEGORIZATION_QUEUE', 'ai'),
];

View File

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Enums\RuleOrigin;
use App\Models\AutomationRule;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -29,6 +30,17 @@ class AutomationRuleFactory extends Factory
'action_category_id' => null,
'action_note' => null,
'action_note_iv' => null,
'origin' => RuleOrigin::User,
];
}
/**
* A rule created/maintained by AI auto-categorization.
*/
public function ai(): static
{
return $this->state(fn (array $attributes): array => [
'origin' => RuleOrigin::Ai,
]);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Database\Factories;
use App\Enums\CategorySource;
use App\Models\CategoryCorrection;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<CategoryCorrection>
*/
class CategoryCorrectionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'transaction_id' => Transaction::factory(),
'from_category_id' => null,
'to_category_id' => null,
'source' => CategorySource::Ai,
'confidence' => fake()->randomFloat(3, 0, 1),
];
}
}

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->string('category_source')->nullable()->after('category_id');
$table->decimal('ai_confidence', 4, 3)->nullable()->after('category_source');
$table->foreignUuid('categorized_by_rule_id')
->nullable()
->after('ai_confidence')
->constrained('automation_rules')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropConstrainedForeignId('categorized_by_rule_id');
$table->dropColumn(['category_source', 'ai_confidence']);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('automation_rules', function (Blueprint $table) {
$table->string('origin')->default('user')->after('priority');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('automation_rules', function (Blueprint $table) {
$table->dropColumn('origin');
});
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('category_corrections', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('transaction_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('from_category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->foreignUuid('to_category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->string('source');
$table->decimal('confidence', 4, 3)->nullable();
$table->timestamps();
$table->index(['source', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('category_corrections');
}
};

View File

@ -1,4 +1,7 @@
{
"Categorized by AI": "Categorizado por IA",
"Categorized by AI with :confidence% confident": "Categorizado por IA con un :confidence % de confianza",
"Only show AI guesses": "Mostrar solo sugerencias de IA",
"Analyze": "Analizar",
"Monthly average": "Media mensual",
"Trend": "Tendencia",

View File

@ -436,7 +436,7 @@ function UpgradeNotice() {
return (
<div className="w-full max-w-md rounded-lg border border-emerald-100 bg-emerald-50 p-3 dark:border-emerald-900/50 dark:bg-emerald-900/20">
<p className="text-center text-balance text-sm text-emerald-700 dark:text-emerald-300">
<p className="text-center text-sm text-balance text-emerald-700 dark:text-emerald-300">
{__(
"AI suggestions are a Standard Plan feature. You'll choose a plan at the end of the onboarding.",
)}

View File

@ -26,7 +26,14 @@ import {
HelpCircle,
type LucideIcon,
} from 'lucide-react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import {
memo,
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
const iconCache = new Map<string, LucideIcon>();
@ -55,6 +62,8 @@ interface CategoryComboboxProps {
withoutChevronIcon?: boolean;
/** Label for the empty / "no category" option (defaults to Uncategorized). */
emptyOptionLabel?: string;
/** Optional content rendered at the very top of the open dropdown. */
header?: ReactNode;
'data-testid'?: string;
}
@ -68,6 +77,7 @@ export function CategoryCombobox({
showUncategorized = true,
withoutChevronIcon = false,
emptyOptionLabel,
header,
'data-testid': dataTestId,
}: CategoryComboboxProps) {
const noneLabel = emptyOptionLabel ?? __('Uncategorized');
@ -175,6 +185,7 @@ export function CategoryCombobox({
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
{header}
<Command shouldFilter={false}>
<CommandInput
placeholder={__('Search categories...')}

View File

@ -0,0 +1,34 @@
import { cn } from '@/lib/utils';
import { Sparkles } from 'lucide-react';
/**
* The Gemini-style multi-color sparkle used to mark an AI-guessed category.
* The gradient is applied to the SVG via a referenced linearGradient so the
* stroke and fill render as a true multi-color gradient (not a flat color).
*/
export function AiSparkleIcon({ className }: { className?: string }) {
return (
<>
<svg width="0" height="0" className="absolute" aria-hidden="true">
<defs>
<linearGradient
id="ai-sparkle-gradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor="#4796E3" />
<stop offset="50%" stopColor="#9177C7" />
<stop offset="100%" stopColor="#D56F82" />
</linearGradient>
</defs>
</svg>
<Sparkles
className={cn('h-4 w-4', className)}
stroke="url(#ai-sparkle-gradient)"
fill="url(#ai-sparkle-gradient)"
/>
</>
);
}

View File

@ -1,4 +1,12 @@
import { AiSparkleIcon } from '@/components/transactions/ai-sparkle-icon';
import { CategorySelect } from '@/components/transactions/category-select';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account, type Bank } from '@/types/account';
@ -33,6 +41,7 @@ export function CategoryCell({
withoutChevronIcon,
}: CategoryCellProps) {
const [isUpdating, setIsUpdating] = useState(false);
const isMobile = useIsMobile();
async function handleCategoryChange(value: string) {
const categoryId = value === 'null' ? null : value;
@ -62,6 +71,9 @@ export function CategoryCell({
...transaction,
category_id: categoryId,
category: updatedCategory,
category_source: categoryId ? 'manual' : null,
ai_confidence: null,
ai_categorized: false,
account,
bank,
};
@ -82,23 +94,77 @@ export function CategoryCell({
}
}
const isAiCategorized = transaction.ai_categorized === true;
const confidencePercent =
transaction.ai_confidence != null
? Math.round(transaction.ai_confidence * 100)
: null;
const aiNote =
confidencePercent != null
? __('Categorized by AI with :confidence% confident', {
confidence: confidencePercent,
})
: __('Categorized by AI');
// On mobile there is no hover, so the confidence is shown as a row at the
// top of the open dropdown instead of in a tooltip.
const aiHeader =
isAiCategorized && isMobile ? (
<div className="flex items-center gap-2 border-b px-3 py-2 text-xs text-muted-foreground">
<AiSparkleIcon className="h-3.5 w-3.5 shrink-0" />
<span>{aiNote}</span>
</div>
) : undefined;
const aiIcon = (
<span className="inline-flex" aria-label={__('Categorized by AI')}>
<AiSparkleIcon className="h-3.5 w-3.5" />
</span>
);
return (
<CategorySelect
value={
transaction.category_id
? String(transaction.category_id)
: 'null'
}
onValueChange={handleCategoryChange}
categories={categories}
disabled={isUpdating}
placeholder={__('Uncategorized')}
triggerClassName={cn(
'h-auto w-auto border-0 bg-transparent p-0 shadow-none focus:ring-0',
className || '',
)}
showUncategorized={true}
withoutChevronIcon={withoutChevronIcon}
/>
<div className="flex w-full items-center gap-1">
<div className="min-w-0 flex-1">
<CategorySelect
value={
transaction.category_id
? String(transaction.category_id)
: 'null'
}
onValueChange={handleCategoryChange}
categories={categories}
disabled={isUpdating}
placeholder={__('Uncategorized')}
triggerClassName={cn(
'h-auto w-full border-0 bg-transparent p-0 shadow-none focus:ring-0',
className || '',
)}
showUncategorized={true}
withoutChevronIcon={withoutChevronIcon}
header={aiHeader}
/>
</div>
{/* Trailing icon in a fixed-width slot pinned to the column's right
edge always reserved (empty when not AI) so the icon lines up
horizontally on every row. Desktop shows confidence on hover;
mobile relies on the dropdown header. */}
<span className="flex w-3.5 shrink-0 items-center justify-center">
{isAiCategorized &&
(isMobile ? (
aiIcon
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{aiIcon}
</TooltipTrigger>
<TooltipContent>{aiNote}</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</span>
</div>
);
}

View File

@ -92,12 +92,13 @@ export function createTransactionColumns({
accessorKey: 'transaction_date',
meta: {
label: __('Date'),
cellClassName: 'max-w-[95px] whitespace-normal',
cellClassName: 'max-w-[90px] whitespace-normal pr-1',
},
header: ({ column }) => {
return (
<Button
variant="ghost"
className="px-0"
onClick={() =>
column.toggleSorting(column.getIsSorted() === 'asc')
}
@ -136,7 +137,7 @@ export function createTransactionColumns({
meta: {
label: __('Category'),
cellClassName:
'max-w-[170px] !sm:max-w-[170px] md:max-w-[190px] !min-w-[170px] whitespace-normal',
'pl-0 max-w-[170px] !sm:max-w-[170px] md:max-w-[190px] !min-w-[170px] whitespace-normal',
},
header: () => __('Category'),
cell: ({ row }) => {

View File

@ -6,6 +6,7 @@ import { ChevronsUpDown, Tag, X } from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { AccountName } from '@/components/accounts/account-name';
import { AiSparkleIcon } from '@/components/transactions/ai-sparkle-icon';
import { SavedFilters } from '@/components/transactions/saved-filters';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -221,6 +222,7 @@ export function TransactionFilters({
creditorName: '',
debtorName: '',
searchText: '',
aiCategorizedOnly: false,
});
}
@ -233,6 +235,7 @@ export function TransactionFilters({
filters.labelIds.length +
(filters.creditorName ? 1 : 0) +
(filters.debtorName ? 1 : 0) +
(filters.aiCategorizedOnly ? 1 : 0) +
(hideAccountFilter ? 0 : filters.accountIds.length);
return (
@ -693,6 +696,32 @@ export function TransactionFilters({
</div>
</div>
)}
<div className="space-y-2">
<FormLabel>
{__('Categorized by AI')}
</FormLabel>
<div className="flex flex-wrap gap-2 pt-2">
<Badge
variant={
filters.aiCategorizedOnly
? 'default'
: 'outline'
}
className="flex cursor-pointer items-center gap-1 px-2 py-1"
onClick={() =>
onFiltersChange({
...filters,
aiCategorizedOnly:
!filters.aiCategorizedOnly,
})
}
>
<AiSparkleIcon className="h-3.5 w-3.5" />
{__('Only show AI guesses')}
</Badge>
</div>
</div>
</div>
</PopoverContent>
</Popover>

View File

@ -113,6 +113,7 @@ interface AppliedFilters {
label_ids: string[];
creditor_name: string;
debtor_name: string;
category_source: string | null;
search: string;
sort: string;
}
@ -145,6 +146,7 @@ function serverToClientFilters(applied: AppliedFilters): Filters {
creditorName: applied.creditor_name,
debtorName: applied.debtor_name,
searchText: applied.search,
aiCategorizedOnly: applied.category_source === 'ai',
};
}
@ -184,6 +186,9 @@ function clientFiltersToQueryParams(
if (filters.searchText) {
params.search = filters.searchText;
}
if (filters.aiCategorizedOnly) {
params.category_source = 'ai';
}
if (sort !== '-transaction_date') {
params.sort = sort;
}
@ -226,6 +231,9 @@ function clientFiltersToBackendFilters(
if (filters.searchText) {
result.search = filters.searchText;
}
if (filters.aiCategorizedOnly) {
result.category_source = 'ai';
}
return result;
}

View File

@ -5,6 +5,8 @@ import { UUID } from './uuid';
export type TransactionSource = 'manually_created' | 'imported';
export type CategorySource = 'manual' | 'rule' | 'ai' | 'bank';
export interface Transaction {
id: UUID;
user_id: UUID;
@ -20,6 +22,9 @@ export interface Transaction {
creditor_name?: string | null;
debtor_name?: string | null;
source: TransactionSource;
category_source?: CategorySource | null;
ai_confidence?: number | null;
ai_categorized?: boolean;
label_ids?: UUID[];
created_at: string;
updated_at: string;
@ -51,4 +56,5 @@ export interface TransactionFilters {
creditorName: string;
debtorName: string;
searchText: string;
aiCategorizedOnly: boolean;
}

View File

@ -0,0 +1,29 @@
<?php
use App\Features\AiCategorization;
use App\Models\User;
use Laravel\Pennant\Feature;
beforeEach(function () {
config()->set('ai_categorization.rollout_after', '2026-06-13 21:00:00');
});
it('activates for users created after the rollout cutoff', function () {
$user = User::factory()->create(['created_at' => '2026-06-13 21:00:01']);
expect(Feature::for($user)->active(AiCategorization::class))->toBeTrue();
});
it('stays inactive for users created before the rollout cutoff', function () {
$user = User::factory()->create(['created_at' => '2026-06-13 20:59:59']);
expect(Feature::for($user)->active(AiCategorization::class))->toBeFalse();
});
it('is inactive when no rollout cutoff is configured', function () {
config()->set('ai_categorization.rollout_after', null);
$user = User::factory()->create(['created_at' => '2026-06-14 00:00:00']);
expect(Feature::for($user)->active(AiCategorization::class))->toBeFalse();
});

View File

@ -0,0 +1,48 @@
<?php
use App\Features\AiCategorization;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use Laravel\Pennant\Feature;
function eligibleUser(): User
{
$user = User::factory()->create();
$user->recordAiConsent();
Feature::for($user)->activate(AiCategorization::class);
return $user;
}
it('allows an enabled, pro, consented, flagged user', function () {
expect(app(AiCategorizationGate::class)->allows(eligibleUser()))->toBeTrue();
});
it('denies when the master kill switch is off', function () {
config()->set('ai_categorization.enabled', false);
expect(app(AiCategorizationGate::class)->allows(eligibleUser()))->toBeFalse();
});
it('denies a user without active AI consent', function () {
$user = User::factory()->create();
Feature::for($user)->activate(AiCategorization::class);
expect(app(AiCategorizationGate::class)->allows($user))->toBeFalse();
});
it('denies a user the rollout flag is not active for', function () {
config()->set('ai_categorization.rollout_after', '2026-06-13 21:00:00');
// Signed up before the rollout cutoff, so the feature does not resolve on.
$user = User::factory()->create(['created_at' => '2026-06-13 20:00:00']);
$user->recordAiConsent();
expect(app(AiCategorizationGate::class)->allows($user))->toBeFalse();
});
it('denies a non-pro user when subscriptions are enforced', function () {
config()->set('subscriptions.enabled', true);
expect(app(AiCategorizationGate::class)->allows(eligibleUser()))->toBeFalse();
});

View File

@ -0,0 +1,141 @@
<?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;
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('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);
});

View File

@ -0,0 +1,78 @@
<?php
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategorySource;
use App\Enums\CategoryType;
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\AutomationRuleService;
use function Pest\Laravel\actingAs;
function routeCategory(User $user): Category
{
return Category::factory()->for($user)->create([
'type' => CategoryType::Expense,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
]);
}
it('records provenance when an automation rule categorizes a transaction', function () {
$user = User::factory()->create();
$category = routeCategory($user);
$rule = AutomationRule::factory()->for($user)->create([
'action_category_id' => $category->id,
'rules_json' => ['==' => [['var' => 'creditor_name'], 'mercadona']],
]);
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
'creditor_name' => 'mercadona',
]);
app(AutomationRuleService::class)->applyRules($transaction);
$transaction->refresh();
expect($transaction->category_id)->toBe($category->id)
->and($transaction->category_source)->toBe(CategorySource::Rule)
->and($transaction->categorized_by_rule_id)->toBe($rule->id);
});
it('self-heals and logs a correction when the user overrides an ai category via the update route', function () {
$user = User::factory()->create();
$from = routeCategory($user);
$to = routeCategory($user);
// Learn an ai rule, then categorize a matching transaction through it.
app(AiRuleLearner::class)->learn(new CategorizationOutcome(
Transaction::factory()->plaintext()->create([
'user_id' => $user->id, 'category_id' => null, 'creditor_name' => 'Mercadona', 'amount' => -1000,
]),
$from->id, 0.95, true, true,
));
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id, 'category_id' => null, 'creditor_name' => 'Mercadona', 'amount' => -2000,
]);
app(AutomationRuleService::class)->applyRules($transaction);
$rule = $transaction->refresh()->categorized_by_rule_id;
actingAs($user)
->patchJson(route('transactions.update', $transaction), ['category_id' => $to->id])
->assertOk();
$transaction->refresh();
expect($transaction->category_id)->toBe($to->id)
->and($transaction->category_source)->toBe(CategorySource::Manual)
->and($transaction->categorized_by_rule_id)->toBeNull()
->and($transaction->ai_confidence)->toBeNull()
->and(CategoryCorrection::query()->where('transaction_id', $transaction->id)->count())->toBe(1)
->and(AutomationRule::query()->find($rule))->toBeNull();
});

View File

@ -0,0 +1,109 @@
<?php
use App\Enums\CategorySource;
use App\Enums\RuleOrigin;
use App\Models\AutomationRule;
use App\Models\CategoryCorrection;
use App\Models\Transaction;
use App\Models\User;
it('casts the AI categorization fields on a transaction', function () {
$rule = AutomationRule::factory()->ai()->for(User::factory())->create();
$transaction = Transaction::factory()->plaintext()->create([
'category_source' => CategorySource::Ai,
'ai_confidence' => 0.873,
'categorized_by_rule_id' => $rule->id,
]);
$transaction->refresh();
expect($transaction->category_source)->toBe(CategorySource::Ai)
->and($transaction->ai_confidence)->toBeFloat()->toEqual(0.873)
->and($transaction->categorizedByRule->is($rule))->toBeTrue();
});
it('hides the categorizing rule id from serialization but exposes the source', function () {
$transaction = Transaction::factory()->plaintext()->create([
'category_source' => CategorySource::Ai,
'ai_confidence' => 0.5,
]);
$array = $transaction->toArray();
expect($array)->toHaveKey('category_source')
->and($array)->toHaveKey('ai_confidence')
->and($array)->not->toHaveKey('categorized_by_rule_id');
});
it('defaults rule origin to user and supports the ai state and scope', function () {
$user = User::factory()->create();
$userRule = AutomationRule::factory()->for($user)->create();
$aiRule = AutomationRule::factory()->ai()->for($user)->create();
expect($userRule->refresh()->origin)->toBe(RuleOrigin::User)
->and($aiRule->refresh()->origin)->toBe(RuleOrigin::Ai);
$aiRules = AutomationRule::query()->origin(RuleOrigin::Ai)->get();
expect($aiRules)->toHaveCount(1)
->and($aiRules->first()->is($aiRule))->toBeTrue();
});
it('records a category correction with casted fields', function () {
$correction = CategoryCorrection::factory()->create([
'source' => CategorySource::Ai,
'confidence' => 0.612,
]);
$correction->refresh();
expect($correction->source)->toBe(CategorySource::Ai)
->and($correction->confidence)->toBeFloat()->toEqual(0.612)
->and($correction->transaction)->not->toBeNull();
});
it('reports ai_categorized for a direct AI label', function () {
$transaction = Transaction::factory()->plaintext()->create([
'category_source' => CategorySource::Ai,
]);
expect($transaction->ai_categorized)->toBeTrue();
});
it('reports ai_categorized when categorized by an AI-origin rule', function () {
$user = User::factory()->create();
$rule = AutomationRule::factory()->ai()->for($user)->create();
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_source' => CategorySource::Rule,
'categorized_by_rule_id' => $rule->id,
]);
$transaction->load('categorizedByRule');
expect($transaction->ai_categorized)->toBeTrue();
});
it('does not report ai_categorized for a user-owned rule', function () {
$user = User::factory()->create();
$rule = AutomationRule::factory()->for($user)->create();
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_source' => CategorySource::Rule,
'categorized_by_rule_id' => $rule->id,
]);
$transaction->load('categorizedByRule');
expect($transaction->ai_categorized)->toBeFalse();
});
it('does not report ai_categorized for a manual category', function () {
$transaction = Transaction::factory()->plaintext()->create([
'category_source' => CategorySource::Manual,
]);
expect($transaction->ai_categorized)->toBeFalse();
});

View File

@ -0,0 +1,83 @@
<?php
use App\Ai\Agents\TransactionCategorizationAgent;
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\Transaction;
use App\Models\User;
use function Pest\Laravel\artisan;
function bfCategory(User $user): Category
{
return Category::factory()->for($user)->create([
'type' => CategoryType::Expense,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
]);
}
function bfFakeAllToIndex(int $index): void
{
TransactionCategorizationAgent::fake(function (string $prompt) use ($index): array {
preg_match_all('/"ref":"([^"]+)"/', $prompt, $matches);
return ['results' => array_map(fn (string $ref): array => [
'ref' => $ref,
'category_index' => $index,
'confidence' => 0.95,
'merchant_unambiguous' => true,
], $matches[1])];
});
}
it('categorizes a user\'s uncategorized transactions and learns rules', function () {
$user = User::factory()->create();
$user->recordAiConsent();
$category = bfCategory($user);
$transactions = collect(range(1, 3))->map(fn (): Transaction => Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
'amount' => -4300,
'creditor_name' => 'mercadona',
]));
bfFakeAllToIndex(0);
artisan('ai:categorize-backfill', ['user' => $user->id])->assertSuccessful();
$transactions->each(function (Transaction $transaction) use ($category): void {
expect($transaction->refresh()->category_id)->toBe($category->id)
->and($transaction->category_source)->toBe(CategorySource::Ai);
});
expect(AutomationRule::query()->where('user_id', $user->id)->origin(RuleOrigin::Ai)->count())->toBe(1);
});
it('refuses to backfill an ineligible user', function () {
$user = User::factory()->create();
bfCategory($user);
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
'creditor_name' => 'mercadona',
]);
artisan('ai:categorize-backfill', ['user' => $user->id])->assertExitCode(1);
expect($transaction->refresh()->category_id)->toBeNull();
});
it('reports when there is nothing to backfill', function () {
$user = User::factory()->create();
$user->recordAiConsent();
bfCategory($user);
artisan('ai:categorize-backfill', ['user' => $user->id])
->expectsOutputToContain('No uncategorized transactions to backfill.')
->assertSuccessful();
});

View File

@ -0,0 +1,116 @@
<?php
use App\Ai\Agents\TransactionCategorizationAgent;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategorySource;
use App\Enums\CategoryType;
use App\Features\AiCategorization;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\CategoryCatalog;
use Laravel\Pennant\Feature;
function eligible(): User
{
$user = User::factory()->create();
$user->recordAiConsent();
Feature::for($user)->activate(AiCategorization::class);
return $user;
}
function leaf(User $user): Category
{
return Category::factory()->for($user)->create([
'type' => CategoryType::Expense,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
]);
}
function fakeCategorizes(Transaction $marker, int $index): void
{
TransactionCategorizationAgent::fake(function (string $prompt) use ($index) {
// The ref is the transaction id, which the listener passes through.
preg_match('/"ref":"([0-9a-f-]+)"/', $prompt, $m);
return ['results' => [[
'ref' => $m[1] ?? '',
'category_index' => $index,
'confidence' => 0.95,
'merchant_unambiguous' => false,
]]];
});
}
it('categorizes an eligible uncategorized transaction on creation', function () {
$user = eligible();
$category = leaf($user);
$index = (function () use ($user, $category): int {
$catalog = CategoryCatalog::forUser($user);
$i = 0;
while ($catalog->categoryIdForIndex($i) !== null) {
if ($catalog->categoryIdForIndex($i) === $category->id) {
return $i;
}
$i++;
}
throw new RuntimeException('not a leaf');
})();
fakeCategorizes(new Transaction, $index);
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
'amount' => -4300,
'creditor_name' => 'mercadona',
]);
$transaction->refresh();
expect($transaction->category_id)->toBe($category->id)
->and($transaction->category_source)->toBe(CategorySource::Ai);
});
it('does nothing when the user is not eligible', function () {
$user = User::factory()->create();
leaf($user);
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
'amount' => -4300,
'creditor_name' => 'mercadona',
]);
expect($transaction->refresh()->category_id)->toBeNull();
});
it('does not categorize a transaction that already has a category', function () {
$user = eligible();
$category = leaf($user);
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => $category->id,
'category_source' => CategorySource::Manual,
'amount' => -4300,
]);
expect($transaction->refresh()->category_source)->toBe(CategorySource::Manual);
});
it('never categorizes a client-side encrypted transaction', function () {
$user = eligible();
leaf($user);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'category_id' => null,
'description_iv' => str_repeat('a', 16),
'amount' => -4300,
]);
expect($transaction->refresh()->category_id)->toBeNull();
});

View File

@ -0,0 +1,148 @@
<?php
use App\Ai\Agents\TransactionCategorizationAgent;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategorySource;
use App\Enums\CategoryType;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\CategorizeTransactions;
use App\Services\Ai\CategoryCatalog;
function leafIndex(CategoryCatalog $catalog, string $categoryId): int
{
$index = 0;
while (($id = $catalog->categoryIdForIndex($index)) !== null) {
if ($id === $categoryId) {
return $index;
}
$index++;
}
throw new RuntimeException("category {$categoryId} is not a leaf in the catalog");
}
function groceries(User $user): Category
{
return Category::factory()->for($user)->create([
'type' => CategoryType::Expense,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
]);
}
function uncategorized(User $user): Transaction
{
return Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
'category_source' => null,
'amount' => -4300,
'creditor_name' => 'mercadona',
'description' => 'mercadona compra',
]);
}
it('auto-applies the category when confidence clears the label bar', function () {
$user = User::factory()->create();
$category = groceries($user);
$transaction = uncategorized($user);
$index = leafIndex(CategoryCatalog::forUser($user), $category->id);
TransactionCategorizationAgent::fake([
['results' => [[
'ref' => $transaction->id,
'category_index' => $index,
'confidence' => 0.95,
'merchant_unambiguous' => true,
]]],
]);
$outcomes = app(CategorizeTransactions::class)->forTransactions($user, collect([$transaction]));
$transaction->refresh();
expect($transaction->category_id)->toBe($category->id)
->and($transaction->category_source)->toBe(CategorySource::Ai)
->and($transaction->ai_confidence)->toEqual(0.95)
->and($outcomes)->toHaveCount(1)
->and($outcomes[0]->applied)->toBeTrue()
->and($outcomes[0]->merchantUnambiguous)->toBeTrue();
});
it('leaves the transaction blank when confidence is below the label bar', function () {
$user = User::factory()->create();
$category = groceries($user);
$transaction = uncategorized($user);
$index = leafIndex(CategoryCatalog::forUser($user), $category->id);
TransactionCategorizationAgent::fake([
['results' => [[
'ref' => $transaction->id,
'category_index' => $index,
'confidence' => 0.5,
'merchant_unambiguous' => false,
]]],
]);
$outcomes = app(CategorizeTransactions::class)->forTransactions($user, collect([$transaction]));
$transaction->refresh();
expect($transaction->category_id)->toBeNull()
->and($transaction->category_source)->toBeNull()
->and($outcomes)->toHaveCount(1)
->and($outcomes[0]->applied)->toBeFalse();
});
it('returns nothing when the user has no leaf categories', function () {
$user = User::factory()->create();
$transaction = uncategorized($user);
$outcomes = app(CategorizeTransactions::class)->forTransactions($user, collect([$transaction]));
expect($outcomes)->toBe([]);
});
it('never sends client-side encrypted transactions to the model', function () {
$user = User::factory()->create();
groceries($user);
$encrypted = Transaction::factory()->create([
'user_id' => $user->id,
'category_id' => null,
'description_iv' => str_repeat('a', 16),
]);
$outcomes = app(CategorizeTransactions::class)->forTransactions($user, collect([$encrypted]));
$encrypted->refresh();
expect($outcomes)->toBe([])
->and($encrypted->category_id)->toBeNull();
});
it('skips results whose category index does not resolve', function () {
$user = User::factory()->create();
groceries($user);
$transaction = uncategorized($user);
TransactionCategorizationAgent::fake([
['results' => [[
'ref' => $transaction->id,
'category_index' => 999,
'confidence' => 0.99,
'merchant_unambiguous' => true,
]]],
]);
$outcomes = app(CategorizeTransactions::class)->forTransactions($user, collect([$transaction]));
$transaction->refresh();
expect($outcomes)->toBe([])
->and($transaction->category_id)->toBeNull();
});

View File

@ -0,0 +1,155 @@
<?php
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategorySource;
use App\Enums\CategoryType;
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);
});

View File

@ -0,0 +1,51 @@
<?php
use App\Enums\CategorySource;
use App\Models\Transaction;
use App\Models\User;
use function Pest\Laravel\actingAs;
it('filters transactions to only AI-categorized ones', function () {
$user = User::factory()->create();
$ai = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_source' => CategorySource::Ai,
]);
Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_source' => CategorySource::Manual,
]);
Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_source' => null,
]);
$results = Transaction::query()
->where('user_id', $user->id)
->applyFilters(['category_source' => 'ai'])
->get();
expect($results)->toHaveCount(1)
->and($results->first()->is($ai))->toBeTrue();
});
it('applies and echoes the AI filter through the index route', function () {
$user = User::factory()->onboarded()->create();
Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_source' => CategorySource::Ai,
]);
Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_source' => CategorySource::Manual,
]);
actingAs($user)
->get(route('transactions.index', ['category_source' => 'ai']))
->assertInertia(fn ($page) => $page
->where('appliedFilters.category_source', 'ai')
->has('transactions.data', 1),
);
});