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:
parent
1d4bcd5082
commit
8013a0b6f2
|
|
@ -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.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<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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) ?? '');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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) : [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
];
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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...')}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue