fix: Apply automation rule labels on transaction creation and import (#79)

## Summary
- Fixes automation rules not applying labels when creating transactions
manually or via CSV import
- Eager-loads the `labels` relationship on automation rules in both
`TransactionController` and `HandleInertiaRequests`
- Syncs `label_ids` on the transaction `store` endpoint (was accepted
but never persisted)
- Passes `automationRules` prop through the full component chain:
`index.tsx` → `TransactionActionsMenu` → `ImportTransactionsDrawer`, and
`index.tsx` → `EditTransactionDialog`
- Passes `automationRules` as the missing 5th argument to
`reEvaluateAll()`

Closes #61

## Test plan
- [x] Existing feature tests pass (`php artisan test
--filter=Transaction`, `--filter=AutomationRule`)
- [x] Pint, ESLint, and Prettier all pass
- [x] Manually verify: create a transaction matching an automation rule
with labels → labels are applied
- [x] Manually verify: import CSV with transactions matching rules with
labels → labels are applied
- [x] Manually verify: "Re-evaluate All" applies labels from matched
rules
This commit is contained in:
Víctor Falcón 2026-01-27 11:11:29 +01:00 committed by GitHub
parent bc02bf948f
commit a6a2a0d58c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 171 additions and 7 deletions

View File

@ -26,7 +26,7 @@ class ImportDataController extends Controller
->orderBy('name')
->get(['id', 'name', 'logo']),
'automationRules' => $user->automationRules()
->with('category:id,name,icon,color')
->with(['category:id,name,icon,color', 'labels:id,name,color'])
->orderBy('priority')
->get(),
]);

View File

@ -51,6 +51,7 @@ class TransactionController extends Controller
$automationRules = AutomationRule::query()
->where('user_id', $user->id)
->with(['category:id,name,icon,color', 'labels:id,name,color'])
->orderBy('priority')
->get();
@ -102,6 +103,8 @@ class TransactionController extends Controller
public function store(StoreTransactionRequest $request): JsonResponse
{
$data = $request->validated();
$labelIds = $data['label_ids'] ?? null;
unset($data['label_ids']);
$transaction = new Transaction([
...$data,
@ -115,6 +118,10 @@ class TransactionController extends Controller
$transaction->save();
if ($labelIds !== null) {
$transaction->labels()->sync($labelIds);
}
return response()->json([
'data' => $transaction->load('labels:id,name,color'),
], 201);

View File

@ -81,10 +81,16 @@ class HandleInertiaRequests extends Middleware
'banks' => fn () => $user ? $user->banks()
->orderBy('name')
->get(['id', 'name', 'logo']) : [],
'automationRules' => fn () => $user ? $user->automationRules()
->with('category:id,name,icon,color')
->orderBy('priority')
->get() : [],
'automationRules' => function () use ($user) {
if (! $user) {
return [];
}
return $user->automationRules()
->with(['category:id,name,icon,color', 'labels:id,name,color'])
->orderBy('priority')
->get();
},
'labels' => fn () => $user ? $user->labels()
->orderBy('name')
->get(['id', 'name', 'color']) : [],

View File

@ -53,6 +53,16 @@ class StoreAutomationRuleRequest extends FormRequest
];
}
/**
* Decode the rules_json string into an array so the model's array cast doesn't double-encode it.
*/
protected function passedValidation(): void
{
$this->merge([
'rules_json' => json_decode($this->rules_json, true),
]);
}
/**
* Configure the validator instance.
*/

View File

@ -53,6 +53,16 @@ class UpdateAutomationRuleRequest extends FormRequest
];
}
/**
* Decode the rules_json string into an array so the model's array cast doesn't double-encode it.
*/
protected function passedValidation(): void
{
$this->merge([
'rules_json' => json_decode($this->rules_json, true),
]);
}
/**
* Configure the validator instance.
*/

View File

@ -168,6 +168,8 @@ export function EditTransactionDialog({
if (mode !== 'create' || automationRules.length === 0) {
return {
categoryId: null,
labelIds: [] as string[],
matchedLabels: [] as Label[],
notes: null,
notesIv: null,
ruleName: null,
@ -178,6 +180,8 @@ export function EditTransactionDialog({
if (!keyString) {
return {
categoryId: null,
labelIds: [] as string[],
matchedLabels: [] as Label[],
notes: null,
notesIv: null,
ruleName: null,
@ -204,6 +208,8 @@ export function EditTransactionDialog({
if (!result) {
return {
categoryId: null,
labelIds: [] as string[],
matchedLabels: [] as Label[],
notes: null,
notesIv: null,
ruleName: null,
@ -228,6 +234,8 @@ export function EditTransactionDialog({
return {
categoryId: result.categoryId,
labelIds: result.labelIds || [],
matchedLabels: result.labels || [],
notes: finalNotes || null,
notesIv: finalNotesIv,
ruleName: result.rule.title,
@ -355,13 +363,20 @@ export function EditTransactionDialog({
let finalCategoryId = categoryId === 'null' ? null : categoryId;
let finalNotes = notes.trim();
let finalLabelIds = [...selectedLabelIds];
if (ruleResult.categoryId) {
if (ruleResult.categoryId && !finalCategoryId) {
finalCategoryId = ruleResult.categoryId;
}
if (ruleResult.notes) {
finalNotes = ruleResult.notes;
}
if (
ruleResult.labelIds.length > 0 &&
finalLabelIds.length === 0
) {
finalLabelIds = [...ruleResult.labelIds];
}
let encryptedNotes: string | null = null;
let notesIv: string | null = null;
@ -396,6 +411,8 @@ export function EditTransactionDialog({
notes: encryptedNotes,
notes_iv: notesIv,
source: 'manually_created' as const,
label_ids:
finalLabelIds.length > 0 ? finalLabelIds : undefined,
});
const updatedCategory = finalCategoryId
@ -404,6 +421,10 @@ export function EditTransactionDialog({
) || null
: null;
const transactionLabels = labels.filter((l) =>
finalLabelIds.includes(l.id),
);
const newTransaction: DecryptedTransaction = {
...createdTransaction,
decryptedDescription: trimmedDescription,
@ -413,6 +434,8 @@ export function EditTransactionDialog({
bank: selectedAccount.bank?.id
? banks.find((b) => b.id === selectedAccount.bank?.id)
: undefined,
labels: transactionLabels,
label_ids: finalLabelIds,
};
if (updateAccountBalance) {

View File

@ -378,6 +378,7 @@ export function ImportTransactionsDrawer({
let categoryId: string | null = null;
let notes: string | null = null;
let notesIv: string | null = null;
let labelIds: string[] = [];
if (key && rules.length > 0) {
const ruleMatch = await evaluateRulesForNewTransaction(
@ -402,6 +403,12 @@ export function ImportTransactionsDrawer({
notes = ruleMatch.note;
notesIv = ruleMatch.noteIv;
}
if (
ruleMatch.labelIds &&
ruleMatch.labelIds.length > 0
) {
labelIds = ruleMatch.labelIds;
}
}
}
@ -419,6 +426,7 @@ export function ImportTransactionsDrawer({
notes: notes,
notes_iv: notesIv,
source: 'imported' as const,
label_ids: labelIds.length > 0 ? labelIds : undefined,
};
const createdTransaction =

View File

@ -16,6 +16,7 @@ import {
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useReEvaluateAllTransactions } from '@/hooks/use-re-evaluate-all-transactions';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { Link } from '@inertiajs/react';
@ -28,6 +29,7 @@ interface TransactionActionsMenuProps {
categories: Category[];
accounts: Account[];
banks: Bank[];
automationRules?: AutomationRule[];
onAddTransaction: () => void;
transactions: DecryptedTransaction[];
onReEvaluateComplete?: () => void;
@ -37,6 +39,7 @@ export function TransactionActionsMenu({
categories,
accounts,
banks,
automationRules = [],
onAddTransaction,
transactions,
onReEvaluateComplete,
@ -81,7 +84,13 @@ export function TransactionActionsMenu({
setIsReEvaluating(true);
try {
await reEvaluateAll(transactions, categories, accounts, banks);
await reEvaluateAll(
transactions,
categories,
accounts,
banks,
automationRules,
);
onReEvaluateComplete?.();
} finally {
setIsReEvaluating(false);
@ -203,6 +212,7 @@ export function TransactionActionsMenu({
categories={categories}
accounts={accounts}
banks={banks}
automationRules={automationRules}
/>
</>
);

View File

@ -29,6 +29,14 @@ export interface TransactionData {
function normalizeRuleJson(rulesJson: unknown): unknown {
if (typeof rulesJson === 'string') {
try {
const parsed = JSON.parse(rulesJson);
if (typeof parsed === 'object' && parsed !== null) {
return normalizeRuleJson(parsed);
}
} catch {
// Not JSON, treat as a plain string value
}
return rulesJson.toLowerCase();
}

View File

@ -1607,6 +1607,7 @@ export default function Transactions({
categories={categories}
accounts={accounts}
banks={banks}
automationRules={automationRules}
onAddTransaction={() =>
setCreateDialogOpen(true)
}
@ -1717,6 +1718,7 @@ export default function Transactions({
accounts={accounts}
banks={banks}
labels={labels}
automationRules={automationRules}
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={(transaction) => {

View File

@ -0,0 +1,27 @@
<?php
use App\Models\AutomationRule;
use App\Models\Category;
use App\Models\Label;
use App\Models\User;
use function Pest\Laravel\actingAs;
test('import data endpoint includes automation rules with labels', function () {
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$label = Label::factory()->create(['user_id' => $user->id]);
$rule = AutomationRule::factory()->create([
'user_id' => $user->id,
'action_category_id' => $category->id,
]);
$rule->labels()->attach($label->id);
$response = actingAs($user)->getJson('/api/import/data');
$response->assertSuccessful();
$response->assertJsonPath('automationRules.0.category.id', $category->id);
$response->assertJsonPath('automationRules.0.labels.0.id', $label->id);
$response->assertJsonPath('automationRules.0.labels.0.name', $label->name);
});

View File

@ -1,6 +1,7 @@
<?php
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\Budget;
use App\Models\BudgetPeriod;
use App\Models\Category;
@ -31,6 +32,30 @@ test('authenticated users can access transactions page', function () {
);
});
test('transactions page includes automation rules with labels', function () {
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$label = Label::factory()->create(['user_id' => $user->id]);
$rule = AutomationRule::factory()->create([
'user_id' => $user->id,
'action_category_id' => $category->id,
]);
$rule->labels()->attach($label->id);
$response = actingAs($user)->get(route('transactions.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('transactions/index')
->has('automationRules', 1)
->has('automationRules.0.labels', 1)
->where('automationRules.0.labels.0.id', $label->id)
->where('automationRules.0.labels.0.name', $label->name)
->where('automationRules.0.category.id', $category->id)
);
});
test('authenticated users can access categorize transactions page', function () {
$user = User::factory()->onboarded()->create();
@ -431,6 +456,34 @@ test('currency_code is required when creating transaction', function () {
$response->assertJsonValidationErrors(['currency_code']);
});
test('users can create a transaction with labels', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$label1 = Label::factory()->create(['user_id' => $user->id]);
$label2 = Label::factory()->create(['user_id' => $user->id]);
$transactionData = [
'account_id' => $account->id,
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => 5000,
'currency_code' => 'USD',
'source' => 'imported',
'label_ids' => [$label1->id, $label2->id],
];
$response = actingAs($user)->postJson(route('transactions.store'), $transactionData);
$response->assertCreated();
$transaction = Transaction::latest()->first();
expect($transaction->labels)->toHaveCount(2);
expect($transaction->labels->pluck('id')->toArray())->toContain($label1->id, $label2->id);
$response->assertJsonCount(2, 'data.labels');
});
test('users can add labels to a transaction', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);