From 8013a0b6f2d53c6df64bcf6019592dd1f8e477d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 15 Jun 2026 16:35:20 +0200 Subject: [PATCH] feat(ai): auto-categorize transactions with AI (behind flag) (#535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 image --- .../Agents/TransactionCategorizationAgent.php | 66 ++++++ .../Commands/CategorizeBackfillCommand.php | 87 +++++++ app/Enums/CategorySource.php | 26 +++ app/Enums/RuleOrigin.php | 22 ++ app/Features/AiCategorization.php | 35 +++ .../Controllers/TransactionController.php | 36 ++- app/Http/Requests/IndexTransactionRequest.php | 1 + app/Listeners/CategorizeTransactionWithAi.php | 54 +++++ app/Models/AutomationRule.php | 17 ++ app/Models/CategoryCorrection.php | 50 ++++ app/Models/Transaction.php | 45 ++++ app/Providers/AppServiceProvider.php | 2 + app/Services/Ai/AiCategorizationGate.php | 43 ++++ app/Services/Ai/AiCategorizer.php | 36 +++ app/Services/Ai/AiRuleLearner.php | 217 ++++++++++++++++++ app/Services/Ai/CategorizationOutcome.php | 22 ++ app/Services/Ai/CategorizeTransactions.php | 158 +++++++++++++ app/Services/Ai/CategoryCatalog.php | 98 ++++++++ app/Services/Ai/CategoryOverrideHandler.php | 55 +++++ app/Services/AutomationRuleService.php | 5 + config/ai_categorization.php | 86 +++++++ database/factories/AutomationRuleFactory.php | 12 + .../factories/CategoryCorrectionFactory.php | 32 +++ ...orization_fields_to_transactions_table.php | 35 +++ ...1_add_origin_to_automation_rules_table.php | 28 +++ ...0911_create_category_corrections_table.php | 35 +++ lang/es.json | 3 + .../onboarding/step-ai-suggestions.tsx | 2 +- .../components/shared/category-combobox.tsx | 13 +- .../transactions/ai-sparkle-icon.tsx | 34 +++ .../components/transactions/category-cell.tsx | 100 ++++++-- .../transactions/transaction-columns.tsx | 5 +- .../transactions/transaction-filters.tsx | 29 +++ resources/js/pages/transactions/index.tsx | 8 + resources/js/types/transaction.ts | 6 + .../Ai/AiCategorizationFeatureTest.php | 29 +++ tests/Feature/Ai/AiCategorizationGateTest.php | 48 ++++ tests/Feature/Ai/AiRuleLearnerTest.php | 141 ++++++++++++ .../Ai/CategorizationOverrideRouteTest.php | 78 +++++++ tests/Feature/Ai/CategorizationSchemaTest.php | 109 +++++++++ .../Ai/CategorizeBackfillCommandTest.php | 83 +++++++ .../Ai/CategorizeTransactionWithAiTest.php | 116 ++++++++++ .../Feature/Ai/CategorizeTransactionsTest.php | 148 ++++++++++++ .../Ai/CategoryOverrideHandlerTest.php | 155 +++++++++++++ tests/Feature/Ai/CategorySourceFilterTest.php | 51 ++++ 45 files changed, 2437 insertions(+), 24 deletions(-) create mode 100644 app/Ai/Agents/TransactionCategorizationAgent.php create mode 100644 app/Console/Commands/CategorizeBackfillCommand.php create mode 100644 app/Enums/CategorySource.php create mode 100644 app/Enums/RuleOrigin.php create mode 100644 app/Features/AiCategorization.php create mode 100644 app/Listeners/CategorizeTransactionWithAi.php create mode 100644 app/Models/CategoryCorrection.php create mode 100644 app/Services/Ai/AiCategorizationGate.php create mode 100644 app/Services/Ai/AiCategorizer.php create mode 100644 app/Services/Ai/AiRuleLearner.php create mode 100644 app/Services/Ai/CategorizationOutcome.php create mode 100644 app/Services/Ai/CategorizeTransactions.php create mode 100644 app/Services/Ai/CategoryCatalog.php create mode 100644 app/Services/Ai/CategoryOverrideHandler.php create mode 100644 config/ai_categorization.php create mode 100644 database/factories/CategoryCorrectionFactory.php create mode 100644 database/migrations/2026_06_15_120911_add_ai_categorization_fields_to_transactions_table.php create mode 100644 database/migrations/2026_06_15_120911_add_origin_to_automation_rules_table.php create mode 100644 database/migrations/2026_06_15_120911_create_category_corrections_table.php create mode 100644 resources/js/components/transactions/ai-sparkle-icon.tsx create mode 100644 tests/Feature/Ai/AiCategorizationFeatureTest.php create mode 100644 tests/Feature/Ai/AiCategorizationGateTest.php create mode 100644 tests/Feature/Ai/AiRuleLearnerTest.php create mode 100644 tests/Feature/Ai/CategorizationOverrideRouteTest.php create mode 100644 tests/Feature/Ai/CategorizationSchemaTest.php create mode 100644 tests/Feature/Ai/CategorizeBackfillCommandTest.php create mode 100644 tests/Feature/Ai/CategorizeTransactionWithAiTest.php create mode 100644 tests/Feature/Ai/CategorizeTransactionsTest.php create mode 100644 tests/Feature/Ai/CategoryOverrideHandlerTest.php create mode 100644 tests/Feature/Ai/CategorySourceFilterTest.php diff --git a/app/Ai/Agents/TransactionCategorizationAgent.php b/app/Ai/Agents/TransactionCategorizationAgent.php new file mode 100644 index 00000000..ecb06af2 --- /dev/null +++ b/app/Ai/Agents/TransactionCategorizationAgent.php @@ -0,0 +1,66 @@ + 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.0–1.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 + */ + 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(), + ]; + } +} diff --git a/app/Console/Commands/CategorizeBackfillCommand.php b/app/Console/Commands/CategorizeBackfillCommand.php new file mode 100644 index 00000000..32a24244 --- /dev/null +++ b/app/Console/Commands/CategorizeBackfillCommand.php @@ -0,0 +1,87 @@ +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); + } +} diff --git a/app/Enums/CategorySource.php b/app/Enums/CategorySource.php new file mode 100644 index 00000000..98cb453e --- /dev/null +++ b/app/Enums/CategorySource.php @@ -0,0 +1,26 @@ + 'Manual', + self::Rule => 'Rule', + self::Ai => 'AI', + self::Bank => 'Bank', + }; + } + + public function isAi(): bool + { + return $this === self::Ai; + } +} diff --git a/app/Enums/RuleOrigin.php b/app/Enums/RuleOrigin.php new file mode 100644 index 00000000..cbf1b2dc --- /dev/null +++ b/app/Enums/RuleOrigin.php @@ -0,0 +1,22 @@ + 'User', + self::Ai => 'AI', + }; + } + + public function isAi(): bool + { + return $this === self::Ai; + } +} diff --git a/app/Features/AiCategorization.php b/app/Features/AiCategorization.php new file mode 100644 index 00000000..1e3c3eb2 --- /dev/null +++ b/app/Features/AiCategorization.php @@ -0,0 +1,35 @@ +created_at !== null + && $user->created_at->gt(Carbon::parse($rolloutAfter)); + } +} diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index ddfdf4fe..688c64b5 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -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'); diff --git a/app/Http/Requests/IndexTransactionRequest.php b/app/Http/Requests/IndexTransactionRequest.php index 861a3158..e3a2ce6b 100644 --- a/app/Http/Requests/IndexTransactionRequest.php +++ b/app/Http/Requests/IndexTransactionRequest.php @@ -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'], diff --git a/app/Listeners/CategorizeTransactionWithAi.php b/app/Listeners/CategorizeTransactionWithAi.php new file mode 100644 index 00000000..ef6fd667 --- /dev/null +++ b/app/Listeners/CategorizeTransactionWithAi.php @@ -0,0 +1,54 @@ +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])); + } +} diff --git a/app/Models/AutomationRule.php b/app/Models/AutomationRule.php index e95ea658..c9abc248 100644 --- a/app/Models/AutomationRule.php +++ b/app/Models/AutomationRule.php @@ -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 $rules_json + * @property RuleOrigin $origin + */ class AutomationRule extends Model { /** @use HasFactory */ @@ -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 $query + * @return Builder + */ + public function scopeOrigin(Builder $query, RuleOrigin $origin): Builder + { + return $query->where('origin', $origin); + } + /** @return BelongsTo */ public function user(): BelongsTo { diff --git a/app/Models/CategoryCorrection.php b/app/Models/CategoryCorrection.php new file mode 100644 index 00000000..70b1410d --- /dev/null +++ b/app/Models/CategoryCorrection.php @@ -0,0 +1,50 @@ + */ + 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 */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** @return BelongsTo */ + public function transaction(): BelongsTo + { + return $this->belongsTo(Transaction::class); + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 2e5c4472..4728207c 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -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 */ + 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 + */ + 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 */ 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); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 965b6635..6de7b220 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); diff --git a/app/Services/Ai/AiCategorizationGate.php b/app/Services/Ai/AiCategorizationGate.php new file mode 100644 index 00000000..4cf46a82 --- /dev/null +++ b/app/Services/Ai/AiCategorizationGate.php @@ -0,0 +1,43 @@ +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(); + } +} diff --git a/app/Services/Ai/AiCategorizer.php b/app/Services/Ai/AiCategorizer.php new file mode 100644 index 00000000..a087c83f --- /dev/null +++ b/app/Services/Ai/AiCategorizer.php @@ -0,0 +1,36 @@ + $transactions + * @return list + */ + public function run(User $user, Collection $transactions): array + { + $outcomes = $this->categorizer->forTransactions($user, $transactions); + + foreach ($outcomes as $outcome) { + $this->learner->learn($outcome); + } + + return $outcomes; + } +} diff --git a/app/Services/Ai/AiRuleLearner.php b/app/Services/Ai/AiRuleLearner.php new file mode 100644 index 00000000..e7ff6888 --- /dev/null +++ b/app/Services/Ai/AiRuleLearner.php @@ -0,0 +1,217 @@ +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> + */ + 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> $clauses + * @return list + */ + 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 $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)) ?? ''); + } +} diff --git a/app/Services/Ai/CategorizationOutcome.php b/app/Services/Ai/CategorizationOutcome.php new file mode 100644 index 00000000..0939c8a4 --- /dev/null +++ b/app/Services/Ai/CategorizationOutcome.php @@ -0,0 +1,22 @@ + $transactions + * @return list + */ + 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 $transactions + * @return list> + */ + 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 $chunk + * @return list> + */ + private function resolveChunkWithRetry(Collection $chunk, CategoryCatalog $catalog): array + { + try { + return $this->resolveChunk($chunk, $catalog); + } catch (Throwable) { + return $this->resolveChunk($chunk, $catalog); + } + } + + /** + * @param Collection $chunk + * @return list> + */ + 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) : []; + } +} diff --git a/app/Services/Ai/CategoryCatalog.php b/app/Services/Ai/CategoryCatalog.php new file mode 100644 index 00000000..769a9772 --- /dev/null +++ b/app/Services/Ai/CategoryCatalog.php @@ -0,0 +1,98 @@ + $idByIndex index => category id + * @param list $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 + */ + 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 $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); + } +} diff --git a/app/Services/Ai/CategoryOverrideHandler.php b/app/Services/Ai/CategoryOverrideHandler.php new file mode 100644 index 00000000..318319e8 --- /dev/null +++ b/app/Services/Ai/CategoryOverrideHandler.php @@ -0,0 +1,55 @@ +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); + } + } +} diff --git a/app/Services/AutomationRuleService.php b/app/Services/AutomationRuleService.php index 2069d415..c25ba330 100644 --- a/app/Services/AutomationRuleService.php +++ b/app/Services/AutomationRuleService.php @@ -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; } diff --git a/config/ai_categorization.php b/config/ai_categorization.php new file mode 100644 index 00000000..68e5c784 --- /dev/null +++ b/config/ai_categorization.php @@ -0,0 +1,86 @@ + 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'), + +]; diff --git a/database/factories/AutomationRuleFactory.php b/database/factories/AutomationRuleFactory.php index ad327899..1815ac4d 100644 --- a/database/factories/AutomationRuleFactory.php +++ b/database/factories/AutomationRuleFactory.php @@ -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, + ]); + } } diff --git a/database/factories/CategoryCorrectionFactory.php b/database/factories/CategoryCorrectionFactory.php new file mode 100644 index 00000000..f3cabfaa --- /dev/null +++ b/database/factories/CategoryCorrectionFactory.php @@ -0,0 +1,32 @@ + + */ +class CategoryCorrectionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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), + ]; + } +} diff --git a/database/migrations/2026_06_15_120911_add_ai_categorization_fields_to_transactions_table.php b/database/migrations/2026_06_15_120911_add_ai_categorization_fields_to_transactions_table.php new file mode 100644 index 00000000..f3e0ad79 --- /dev/null +++ b/database/migrations/2026_06_15_120911_add_ai_categorization_fields_to_transactions_table.php @@ -0,0 +1,35 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_06_15_120911_add_origin_to_automation_rules_table.php b/database/migrations/2026_06_15_120911_add_origin_to_automation_rules_table.php new file mode 100644 index 00000000..3ee7b7fd --- /dev/null +++ b/database/migrations/2026_06_15_120911_add_origin_to_automation_rules_table.php @@ -0,0 +1,28 @@ +string('origin')->default('user')->after('priority'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('automation_rules', function (Blueprint $table) { + $table->dropColumn('origin'); + }); + } +}; diff --git a/database/migrations/2026_06_15_120911_create_category_corrections_table.php b/database/migrations/2026_06_15_120911_create_category_corrections_table.php new file mode 100644 index 00000000..233b9748 --- /dev/null +++ b/database/migrations/2026_06_15_120911_create_category_corrections_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/lang/es.json b/lang/es.json index 0fe93a97..5bf4a30f 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/resources/js/components/onboarding/step-ai-suggestions.tsx b/resources/js/components/onboarding/step-ai-suggestions.tsx index cd373bb6..1ac5b69b 100644 --- a/resources/js/components/onboarding/step-ai-suggestions.tsx +++ b/resources/js/components/onboarding/step-ai-suggestions.tsx @@ -436,7 +436,7 @@ function UpgradeNotice() { return (
-

+

{__( "AI suggestions are a Standard Plan feature. You'll choose a plan at the end of the onboarding.", )} diff --git a/resources/js/components/shared/category-combobox.tsx b/resources/js/components/shared/category-combobox.tsx index fd6674d1..ef1ffb10 100644 --- a/resources/js/components/shared/category-combobox.tsx +++ b/resources/js/components/shared/category-combobox.tsx @@ -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(); @@ -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({ + {header} + + + + ); +} diff --git a/resources/js/components/transactions/category-cell.tsx b/resources/js/components/transactions/category-cell.tsx index 49b19639..bb887a28 100644 --- a/resources/js/components/transactions/category-cell.tsx +++ b/resources/js/components/transactions/category-cell.tsx @@ -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 ? ( +

+ + {aiNote} +
+ ) : undefined; + + const aiIcon = ( + + + + ); + return ( - +
+
+ +
+ + {/* 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. */} + + {isAiCategorized && + (isMobile ? ( + aiIcon + ) : ( + + + + {aiIcon} + + {aiNote} + + + ))} + +
); } diff --git a/resources/js/components/transactions/transaction-columns.tsx b/resources/js/components/transactions/transaction-columns.tsx index 606fe1c6..e96fb5cd 100644 --- a/resources/js/components/transactions/transaction-columns.tsx +++ b/resources/js/components/transactions/transaction-columns.tsx @@ -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 (
)} + +
+ + {__('Categorized by AI')} + +
+ + onFiltersChange({ + ...filters, + aiCategorizedOnly: + !filters.aiCategorizedOnly, + }) + } + > + + {__('Only show AI guesses')} + +
+
diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index a573704f..ddfed952 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -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; } diff --git a/resources/js/types/transaction.ts b/resources/js/types/transaction.ts index 1520124f..76c78bff 100644 --- a/resources/js/types/transaction.ts +++ b/resources/js/types/transaction.ts @@ -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; } diff --git a/tests/Feature/Ai/AiCategorizationFeatureTest.php b/tests/Feature/Ai/AiCategorizationFeatureTest.php new file mode 100644 index 00000000..e09eaaee --- /dev/null +++ b/tests/Feature/Ai/AiCategorizationFeatureTest.php @@ -0,0 +1,29 @@ +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(); +}); diff --git a/tests/Feature/Ai/AiCategorizationGateTest.php b/tests/Feature/Ai/AiCategorizationGateTest.php new file mode 100644 index 00000000..89b61104 --- /dev/null +++ b/tests/Feature/Ai/AiCategorizationGateTest.php @@ -0,0 +1,48 @@ +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(); +}); diff --git a/tests/Feature/Ai/AiRuleLearnerTest.php b/tests/Feature/Ai/AiRuleLearnerTest.php new file mode 100644 index 00000000..53d135bc --- /dev/null +++ b/tests/Feature/Ai/AiRuleLearnerTest.php @@ -0,0 +1,141 @@ +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); +}); diff --git a/tests/Feature/Ai/CategorizationOverrideRouteTest.php b/tests/Feature/Ai/CategorizationOverrideRouteTest.php new file mode 100644 index 00000000..4eb8e5ed --- /dev/null +++ b/tests/Feature/Ai/CategorizationOverrideRouteTest.php @@ -0,0 +1,78 @@ +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(); +}); diff --git a/tests/Feature/Ai/CategorizationSchemaTest.php b/tests/Feature/Ai/CategorizationSchemaTest.php new file mode 100644 index 00000000..e227f680 --- /dev/null +++ b/tests/Feature/Ai/CategorizationSchemaTest.php @@ -0,0 +1,109 @@ +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(); +}); diff --git a/tests/Feature/Ai/CategorizeBackfillCommandTest.php b/tests/Feature/Ai/CategorizeBackfillCommandTest.php new file mode 100644 index 00000000..e61c6574 --- /dev/null +++ b/tests/Feature/Ai/CategorizeBackfillCommandTest.php @@ -0,0 +1,83 @@ +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(); +}); diff --git a/tests/Feature/Ai/CategorizeTransactionWithAiTest.php b/tests/Feature/Ai/CategorizeTransactionWithAiTest.php new file mode 100644 index 00000000..2419acdf --- /dev/null +++ b/tests/Feature/Ai/CategorizeTransactionWithAiTest.php @@ -0,0 +1,116 @@ +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(); +}); diff --git a/tests/Feature/Ai/CategorizeTransactionsTest.php b/tests/Feature/Ai/CategorizeTransactionsTest.php new file mode 100644 index 00000000..e8a27cf7 --- /dev/null +++ b/tests/Feature/Ai/CategorizeTransactionsTest.php @@ -0,0 +1,148 @@ +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(); +}); diff --git a/tests/Feature/Ai/CategoryOverrideHandlerTest.php b/tests/Feature/Ai/CategoryOverrideHandlerTest.php new file mode 100644 index 00000000..164c33bf --- /dev/null +++ b/tests/Feature/Ai/CategoryOverrideHandlerTest.php @@ -0,0 +1,155 @@ +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); +}); diff --git a/tests/Feature/Ai/CategorySourceFilterTest.php b/tests/Feature/Ai/CategorySourceFilterTest.php new file mode 100644 index 00000000..c75fd08c --- /dev/null +++ b/tests/Feature/Ai/CategorySourceFilterTest.php @@ -0,0 +1,51 @@ +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), + ); +});