From c1fbd4d09fe67a092ad45e49b97ce7a172cf9913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Tue, 11 Nov 2025 12:20:39 +0000 Subject: [PATCH] feat(TransactionController): Add store method for creating transactions --- .../Controllers/TransactionController.php | 22 ++ .../transactions/edit-transaction-dialog.tsx | 367 +++++++++++++++--- .../transactions/transaction-actions-menu.tsx | 124 ++++++ resources/js/pages/transactions/index.tsx | 20 +- routes/web.php | 1 + tests/Feature/TransactionTest.php | 187 +++++++++ 6 files changed, 658 insertions(+), 63 deletions(-) create mode 100644 resources/js/components/transactions/transaction-actions-menu.tsx diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 748fbb0f..16818e4e 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Http\Requests\BulkUpdateTransactionsRequest; +use App\Http\Requests\StoreTransactionRequest; use App\Http\Requests\UpdateTransactionRequest; use App\Models\Account; use App\Models\Bank; @@ -48,6 +49,27 @@ class TransactionController extends Controller ]); } + public function store(StoreTransactionRequest $request): JsonResponse + { + $data = $request->validated(); + + $transaction = new Transaction([ + ...$data, + 'user_id' => $request->user()->id, + ]); + + if (isset($data['id'])) { + $transaction->id = $data['id']; + $transaction->exists = false; + } + + $transaction->save(); + + return response()->json([ + 'data' => $transaction, + ], 201); + } + public function update(UpdateTransactionRequest $request, Transaction $transaction): JsonResponse { $this->authorize('update', $transaction); diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index 2ad94a08..053edaf6 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -8,12 +8,21 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; -import { encrypt, importKey } from '@/lib/crypto'; +import { decrypt, encrypt, importKey } from '@/lib/crypto'; import { getStoredKey } from '@/lib/key-storage'; import { transactionSyncService } from '@/services/transaction-sync'; +import { type Account } from '@/types/account'; import { type Category } from '@/types/category'; import { type DecryptedTransaction } from '@/types/transaction'; import { format, parseISO } from 'date-fns'; @@ -23,149 +32,380 @@ import { toast } from 'sonner'; interface EditTransactionDialogProps { transaction: DecryptedTransaction | null; categories: Category[]; + accounts: Account[]; open: boolean; onOpenChange: (open: boolean) => void; onSuccess: (transaction: DecryptedTransaction) => void; + mode: 'create' | 'edit'; } export function EditTransactionDialog({ transaction, categories, + accounts, open, onOpenChange, onSuccess, + mode, }: EditTransactionDialogProps) { const { isKeySet } = useEncryptionKey(); + const [transactionDate, setTransactionDate] = useState(''); + const [description, setDescription] = useState(''); + const [amount, setAmount] = useState(''); + const [accountId, setAccountId] = useState(''); const [categoryId, setCategoryId] = useState('null'); const [notes, setNotes] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [decryptedAccountNames, setDecryptedAccountNames] = useState< + Map + >(new Map()); useEffect(() => { - if (transaction) { + if (mode === 'edit' && transaction) { + setTransactionDate(transaction.transaction_date); + setDescription(transaction.decryptedDescription); + setAmount(transaction.amount); + setAccountId(String(transaction.account_id)); setCategoryId( transaction.category_id ? String(transaction.category_id) : 'null', ); setNotes(transaction.decryptedNotes || ''); + } else if (mode === 'create' && open) { + const today = new Date().toISOString().split('T')[0]; + setTransactionDate(today); + setDescription(''); + setAmount(''); + setAccountId(accounts.length > 0 ? String(accounts[0].id) : ''); + setCategoryId('null'); + setNotes(''); } - }, [transaction]); + }, [mode, transaction, open, accounts]); + + useEffect(() => { + if (!open || mode !== 'create') return; + + async function decryptAccountNames() { + const keyString = getStoredKey(); + if (!keyString) { + return; + } + + try { + const key = await importKey(keyString); + const decryptedNames = new Map(); + + await Promise.all( + accounts.map(async (account) => { + try { + const decryptedName = await decrypt( + account.name, + key, + account.name_iv, + ); + decryptedNames.set(account.id, decryptedName); + } catch (error) { + console.error( + 'Failed to decrypt account name:', + account.id, + error, + ); + decryptedNames.set(account.id, '[Encrypted]'); + } + }), + ); + + setDecryptedAccountNames(decryptedNames); + } catch (error) { + console.error('Failed to decrypt account names:', error); + } + } + + decryptAccountNames(); + }, [open, mode, accounts]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!transaction) { - return; - } if (!isKeySet) { toast.error( - 'Please unlock your encryption key to update transactions', + 'Please unlock your encryption key to save transactions', ); return; } + if (mode === 'create') { + if (!description.trim()) { + toast.error('Description is required'); + return; + } + if (!amount || parseFloat(amount) === 0) { + toast.error('Amount is required'); + return; + } + if (!accountId) { + toast.error('Account is required'); + return; + } + if (!transactionDate) { + toast.error('Date is required'); + return; + } + } + setIsSubmitting(true); try { const selectedCategoryId = categoryId === 'null' ? null : parseInt(categoryId, 10); const trimmedNotes = notes.trim(); + const trimmedDescription = description.trim(); + + const keyString = getStoredKey(); + if (!keyString) { + throw new Error('Encryption key not available'); + } + const key = await importKey(keyString); + let encryptedNotes: string | null = null; let notesIv: string | null = null; if (trimmedNotes) { - const keyString = getStoredKey(); - if (!keyString) { - throw new Error('Encryption key not available'); - } - const key = await importKey(keyString); const encrypted = await encrypt(trimmedNotes, key); encryptedNotes = encrypted.encrypted; notesIv = encrypted.iv; } - await transactionSyncService.update(transaction.id, { - category_id: selectedCategoryId, - notes: encryptedNotes, - notes_iv: notesIv, - }); + if (mode === 'create') { + const encryptedDescription = await encrypt( + trimmedDescription, + key, + ); - const updatedRecord = await transactionSyncService.getById( - transaction.id, - ); - const updatedCategory = selectedCategoryId - ? categories.find( - (category) => category.id === selectedCategoryId, - ) || null - : null; + const selectedAccount = accounts.find( + (acc) => acc.id === parseInt(accountId, 10), + ); + if (!selectedAccount) { + throw new Error('Selected account not found'); + } - const updatedTransaction: DecryptedTransaction = { - ...transaction, - category_id: selectedCategoryId, - category: updatedCategory, - decryptedNotes: trimmedNotes || null, - notes: encryptedNotes, - notes_iv: notesIv, - updated_at: updatedRecord?.updated_at ?? transaction.updated_at, - }; + await transactionSyncService.create({ + user_id: 0, + account_id: parseInt(accountId, 10), + category_id: selectedCategoryId, + description: encryptedDescription.encrypted, + description_iv: encryptedDescription.iv, + transaction_date: transactionDate, + amount: amount, + currency_code: selectedAccount.currency_code, + notes: encryptedNotes, + notes_iv: notesIv, + }); - onSuccess(updatedTransaction); - onOpenChange(false); + toast.success('Transaction created successfully'); + onOpenChange(false); + } else { + if (!transaction) { + return; + } + + await transactionSyncService.update(transaction.id, { + category_id: selectedCategoryId, + notes: encryptedNotes, + notes_iv: notesIv, + }); + + const updatedRecord = await transactionSyncService.getById( + transaction.id, + ); + const updatedCategory = selectedCategoryId + ? categories.find( + (category) => category.id === selectedCategoryId, + ) || null + : null; + + const updatedTransaction: DecryptedTransaction = { + ...transaction, + category_id: selectedCategoryId, + category: updatedCategory, + decryptedNotes: trimmedNotes || null, + notes: encryptedNotes, + notes_iv: notesIv, + updated_at: + updatedRecord?.updated_at ?? transaction.updated_at, + }; + + toast.success('Transaction updated successfully'); + onSuccess(updatedTransaction); + onOpenChange(false); + } } catch (error) { - console.error('Failed to update transaction:', error); + console.error('Failed to save transaction:', error); + toast.error( + `Failed to ${mode === 'create' ? 'create' : 'update'} transaction`, + ); } finally { setIsSubmitting(false); } } - if (!transaction) { - return null; - } + const selectedAccount = accounts.find( + (acc) => acc.id === parseInt(accountId, 10), + ); return ( - Edit Transaction + + {mode === 'create' + ? 'Add Transaction' + : 'Edit Transaction'} + - Update the category and notes for this transaction. + {mode === 'create' + ? 'Create a new transaction.' + : 'Update the category and notes for this transaction.'}
-
-
-
+ {mode === 'create' && ( +
+ + +
+ )} +
setNotes(e.target.value)} rows={3} + disabled={isSubmitting} />
@@ -201,7 +442,11 @@ export function EditTransactionDialog({ Cancel
diff --git a/resources/js/components/transactions/transaction-actions-menu.tsx b/resources/js/components/transactions/transaction-actions-menu.tsx new file mode 100644 index 00000000..56eeb787 --- /dev/null +++ b/resources/js/components/transactions/transaction-actions-menu.tsx @@ -0,0 +1,124 @@ +import { Button } from '@/components/ui/button'; +import { ButtonGroup } from '@/components/ui/button-group'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useEncryptionKey } from '@/contexts/encryption-key-context'; +import { type Account, type Bank } from '@/types/account'; +import { type Category } from '@/types/category'; +import { ChevronDown, Plus, Upload } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { ImportTransactionsDrawer } from './import-transactions-drawer'; + +interface TransactionActionsMenuProps { + categories: Category[]; + accounts: Account[]; + banks: Bank[]; + onAddTransaction: () => void; +} + +export function TransactionActionsMenu({ + categories, + accounts, + banks, + onAddTransaction, +}: TransactionActionsMenuProps) { + const { isKeySet } = useEncryptionKey(); + const [importDrawerOpen, setImportDrawerOpen] = useState(false); + + const handleAddTransaction = () => { + if (!isKeySet) { + toast.error( + 'Please unlock your encryption key to add transactions', + ); + return; + } + onAddTransaction(); + }; + + const handleOpenImportDrawer = () => { + if (!isKeySet) { + toast.error( + 'Please unlock your encryption key to import transactions', + ); + return; + } + setImportDrawerOpen(true); + }; + + return ( + <> + + + + + + + + {!isKeySet + ? 'Unlock encryption to add transactions' + : 'Create a new transaction'} + + + + + + + + + + + + + More actions + + + + + + Import Transactions + + + + + + + + ); +} + + diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 205c1289..50883e5d 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -17,7 +17,7 @@ import { index as transactionsIndex } from '@/actions/App/Http/Controllers/Trans import HeadingSmall from '@/components/heading-small'; import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar'; import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog'; -import { ImportTransactionsButton } from '@/components/transactions/import-transactions-button'; +import { TransactionActionsMenu } from '@/components/transactions/transaction-actions-menu'; import { createTransactionColumns } from '@/components/transactions/transaction-columns'; import { TransactionFilters } from '@/components/transactions/transaction-filters'; import { @@ -109,6 +109,7 @@ export default function Transactions({ categories, accounts, banks }: Props) { }); const [editTransaction, setEditTransaction] = useState(null); + const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteTransaction, setDeleteTransaction] = useState(null); const [isDeleting, setIsDeleting] = useState(false); @@ -907,10 +908,13 @@ export default function Transactions({ categories, accounts, banks }: Props) { isKeySet={isKeySet} actions={ <> - + setCreateDialogOpen(true) + } /> @@ -974,9 +978,21 @@ export default function Transactions({ categories, accounts, banks }: Props) { !open && setEditTransaction(null)} onSuccess={updateTransaction} + mode="edit" + /> + + {}} + mode="create" /> group(function ( })->name('dashboard'); Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index'); + Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store'); Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update'); Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update'); Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy'); diff --git a/tests/Feature/TransactionTest.php b/tests/Feature/TransactionTest.php index ee8b5023..affe5c34 100644 --- a/tests/Feature/TransactionTest.php +++ b/tests/Feature/TransactionTest.php @@ -214,3 +214,190 @@ test('transactions index page passes user accounts', function () { ->has('accounts', 1) ); }); + +test('users can create a new transaction', function () { + $user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]); + $account = Account::factory()->create(['user_id' => $user->id]); + $category = Category::factory()->create(['user_id' => $user->id]); + + $transactionData = [ + 'account_id' => $account->id, + 'category_id' => $category->id, + 'description' => 'encrypted_description', + 'description_iv' => str_repeat('d', 16), + 'transaction_date' => '2025-11-11', + 'amount' => '150.50', + 'currency_code' => 'USD', + 'notes' => 'encrypted_notes', + 'notes_iv' => str_repeat('n', 16), + ]; + + $response = actingAs($user)->postJson(route('transactions.store'), $transactionData); + + $response->assertCreated(); + $response->assertJsonStructure([ + 'data' => [ + 'id', + 'user_id', + 'account_id', + 'category_id', + 'description', + 'description_iv', + 'transaction_date', + 'amount', + 'currency_code', + 'notes', + 'notes_iv', + 'created_at', + 'updated_at', + ], + ]); + + $this->assertDatabaseHas('transactions', [ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'category_id' => $category->id, + 'description' => 'encrypted_description', + 'amount' => '150.50', + 'currency_code' => 'USD', + ]); +}); + +test('users can create a transaction without category', function () { + $user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]); + $account = Account::factory()->create(['user_id' => $user->id]); + + $transactionData = [ + 'account_id' => $account->id, + 'category_id' => null, + 'description' => 'encrypted_description', + 'description_iv' => str_repeat('d', 16), + 'transaction_date' => '2025-11-11', + 'amount' => '75.25', + 'currency_code' => 'EUR', + ]; + + $response = actingAs($user)->postJson(route('transactions.store'), $transactionData); + + $response->assertCreated(); + $this->assertDatabaseHas('transactions', [ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'category_id' => null, + 'description' => 'encrypted_description', + 'amount' => '75.25', + ]); +}); + +test('users can create a transaction without notes', function () { + $user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]); + $account = Account::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' => '100.00', + 'currency_code' => 'USD', + ]; + + $response = actingAs($user)->postJson(route('transactions.store'), $transactionData); + + $response->assertCreated(); + $this->assertDatabaseHas('transactions', [ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'notes' => null, + 'notes_iv' => null, + ]); +}); + +test('account_id is required when creating transaction', function () { + $user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]); + + $transactionData = [ + 'description' => 'encrypted_description', + 'description_iv' => str_repeat('d', 16), + 'transaction_date' => '2025-11-11', + 'amount' => '100.00', + 'currency_code' => 'USD', + ]; + + $response = actingAs($user)->postJson(route('transactions.store'), $transactionData); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['account_id']); +}); + +test('description is required when creating transaction', function () { + $user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]); + $account = Account::factory()->create(['user_id' => $user->id]); + + $transactionData = [ + 'account_id' => $account->id, + 'description_iv' => str_repeat('d', 16), + 'transaction_date' => '2025-11-11', + 'amount' => '100.00', + 'currency_code' => 'USD', + ]; + + $response = actingAs($user)->postJson(route('transactions.store'), $transactionData); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['description']); +}); + +test('amount is required when creating transaction', function () { + $user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]); + $account = Account::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', + 'currency_code' => 'USD', + ]; + + $response = actingAs($user)->postJson(route('transactions.store'), $transactionData); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['amount']); +}); + +test('transaction_date is required when creating transaction', function () { + $user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]); + $account = Account::factory()->create(['user_id' => $user->id]); + + $transactionData = [ + 'account_id' => $account->id, + 'description' => 'encrypted_description', + 'description_iv' => str_repeat('d', 16), + 'amount' => '100.00', + 'currency_code' => 'USD', + ]; + + $response = actingAs($user)->postJson(route('transactions.store'), $transactionData); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['transaction_date']); +}); + +test('currency_code is required when creating transaction', function () { + $user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]); + $account = Account::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' => '100.00', + ]; + + $response = actingAs($user)->postJson(route('transactions.store'), $transactionData); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['currency_code']); +});