diff --git a/app/Http/Controllers/Api/ImportDataController.php b/app/Http/Controllers/Api/ImportDataController.php index a519da52..c0ed71fa 100644 --- a/app/Http/Controllers/Api/ImportDataController.php +++ b/app/Http/Controllers/Api/ImportDataController.php @@ -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(), ]); diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 7f313a26..44599670 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -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); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 1b2a85c8..c6ac1b6b 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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']) : [], diff --git a/app/Http/Requests/Settings/StoreAutomationRuleRequest.php b/app/Http/Requests/Settings/StoreAutomationRuleRequest.php index 22127d22..c849df87 100644 --- a/app/Http/Requests/Settings/StoreAutomationRuleRequest.php +++ b/app/Http/Requests/Settings/StoreAutomationRuleRequest.php @@ -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. */ diff --git a/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php b/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php index 982406b7..747a4c08 100644 --- a/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php +++ b/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php @@ -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. */ diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index 0e6b94b5..46cf48db 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -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) { diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index 719139d2..a690d5d5 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -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 = diff --git a/resources/js/components/transactions/transaction-actions-menu.tsx b/resources/js/components/transactions/transaction-actions-menu.tsx index 436d4bd8..57133d26 100644 --- a/resources/js/components/transactions/transaction-actions-menu.tsx +++ b/resources/js/components/transactions/transaction-actions-menu.tsx @@ -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} /> ); diff --git a/resources/js/lib/rule-engine.ts b/resources/js/lib/rule-engine.ts index 13037701..048a7e46 100644 --- a/resources/js/lib/rule-engine.ts +++ b/resources/js/lib/rule-engine.ts @@ -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(); } diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 1edcf4fe..bbc52b48 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -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) => { diff --git a/tests/Feature/ImportDataTest.php b/tests/Feature/ImportDataTest.php new file mode 100644 index 00000000..89ad6523 --- /dev/null +++ b/tests/Feature/ImportDataTest.php @@ -0,0 +1,27 @@ +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); +}); diff --git a/tests/Feature/TransactionTest.php b/tests/Feature/TransactionTest.php index 1201e0c3..9715b00a 100644 --- a/tests/Feature/TransactionTest.php +++ b/tests/Feature/TransactionTest.php @@ -1,6 +1,7 @@ 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]);