diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 4728207c..b3def01d 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -27,6 +27,8 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property ?CategorySource $category_source * @property ?float $ai_confidence * @property ?string $categorized_by_rule_id + * @property ?string $ai_suggested_category_id + * @property ?Carbon $ai_suggested_category_at */ class Transaction extends Model { @@ -47,6 +49,8 @@ class Transaction extends Model 'category_source', 'ai_confidence', 'categorized_by_rule_id', + 'ai_suggested_category_id', + 'ai_suggested_category_at', 'description', 'description_iv', 'original_description', @@ -86,6 +90,7 @@ class Transaction extends Model 'source' => TransactionSource::class, 'category_source' => CategorySource::class, 'ai_confidence' => 'float', + 'ai_suggested_category_at' => 'datetime', 'raw_data' => 'array', ]; } @@ -114,6 +119,12 @@ class Transaction extends Model return $this->belongsTo(AutomationRule::class, 'categorized_by_rule_id'); } + /** @return BelongsTo */ + public function suggestedCategory(): BelongsTo + { + return $this->belongsTo(Category::class, 'ai_suggested_category_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 diff --git a/app/Services/Ai/CategorizeTransactions.php b/app/Services/Ai/CategorizeTransactions.php index 14b090b0..b36c2137 100644 --- a/app/Services/Ai/CategorizeTransactions.php +++ b/app/Services/Ai/CategorizeTransactions.php @@ -62,9 +62,7 @@ class CategorizeTransactions $confidence = (float) ($result['confidence'] ?? 0.0); $applied = $confidence >= $labelBar; - if ($applied) { - $this->applyLabel($transaction, $categoryId, $confidence); - } + $this->recordOutcome($transaction, $categoryId, $confidence, $applied); $outcomes[] = new CategorizationOutcome( transaction: $transaction, @@ -78,11 +76,23 @@ class CategorizeTransactions return $outcomes; } - private function applyLabel(Transaction $transaction, string $categoryId, float $confidence): void + /** + * Persist the model's suggestion on the transaction whether or not it clears + * the label bar. Below the bar the transaction stays uncategorized but the + * suggestion is kept (for confidence-bar tuning and a future confirm UI); + * at or above it the category is also auto-applied. + */ + private function recordOutcome(Transaction $transaction, string $categoryId, float $confidence, bool $applied): void { - $transaction->category_id = $categoryId; - $transaction->category_source = CategorySource::Ai; + $transaction->ai_suggested_category_id = $categoryId; $transaction->ai_confidence = $confidence; + $transaction->ai_suggested_category_at = now(); + + if ($applied) { + $transaction->category_id = $categoryId; + $transaction->category_source = CategorySource::Ai; + } + $transaction->save(); } diff --git a/database/migrations/2026_06_17_081319_add_ai_suggested_category_to_transactions_table.php b/database/migrations/2026_06_17_081319_add_ai_suggested_category_to_transactions_table.php new file mode 100644 index 00000000..bd0a8f46 --- /dev/null +++ b/database/migrations/2026_06_17_081319_add_ai_suggested_category_to_transactions_table.php @@ -0,0 +1,36 @@ +foreignUuid('ai_suggested_category_id') + ->nullable() + ->after('categorized_by_rule_id') + ->constrained('categories') + ->nullOnDelete(); + $table->timestamp('ai_suggested_category_at') + ->nullable() + ->after('ai_suggested_category_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropConstrainedForeignId('ai_suggested_category_id'); + $table->dropColumn('ai_suggested_category_at'); + }); + } +}; diff --git a/tests/Feature/Ai/CategorizeTransactionsTest.php b/tests/Feature/Ai/CategorizeTransactionsTest.php index e8a27cf7..08951f3c 100644 --- a/tests/Feature/Ai/CategorizeTransactionsTest.php +++ b/tests/Feature/Ai/CategorizeTransactionsTest.php @@ -67,12 +67,14 @@ it('auto-applies the category when confidence clears the label bar', function () expect($transaction->category_id)->toBe($category->id) ->and($transaction->category_source)->toBe(CategorySource::Ai) ->and($transaction->ai_confidence)->toEqual(0.95) + ->and($transaction->ai_suggested_category_id)->toBe($category->id) + ->and($transaction->ai_suggested_category_at)->not->toBeNull() ->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 () { +it('leaves the transaction blank but records the suggestion when confidence is below the label bar', function () { $user = User::factory()->create(); $category = groceries($user); $transaction = uncategorized($user); @@ -94,6 +96,9 @@ it('leaves the transaction blank when confidence is below the label bar', functi expect($transaction->category_id)->toBeNull() ->and($transaction->category_source)->toBeNull() + ->and($transaction->ai_suggested_category_id)->toBe($category->id) + ->and($transaction->ai_confidence)->toEqual(0.5) + ->and($transaction->ai_suggested_category_at)->not->toBeNull() ->and($outcomes)->toHaveCount(1) ->and($outcomes[0]->applied)->toBeFalse(); });