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:
parent
bc02bf948f
commit
a6a2a0d58c
|
|
@ -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(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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']) : [],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue