diff --git a/app/Enums/TransactionSource.php b/app/Enums/TransactionSource.php new file mode 100644 index 00000000..9437b06d --- /dev/null +++ b/app/Enums/TransactionSource.php @@ -0,0 +1,9 @@ + ['required', 'string', 'size:3'], 'notes' => ['nullable', 'string'], 'notes_iv' => ['nullable', 'string', 'size:16'], + 'source' => ['required', Rule::enum(TransactionSource::class)], ]; } @@ -43,6 +46,7 @@ class StoreTransactionRequest extends FormRequest 'currency_code.required' => 'The currency code is required.', 'currency_code.size' => 'The currency code must be exactly 3 characters.', 'notes_iv.size' => 'The notes IV must be exactly 16 characters.', + 'source.required' => 'The transaction source is required.', ]; } } diff --git a/app/Http/Requests/UpdateTransactionRequest.php b/app/Http/Requests/UpdateTransactionRequest.php index 4b316e92..bed5f73f 100644 --- a/app/Http/Requests/UpdateTransactionRequest.php +++ b/app/Http/Requests/UpdateTransactionRequest.php @@ -15,6 +15,8 @@ class UpdateTransactionRequest extends FormRequest { return [ 'category_id' => ['nullable', 'exists:categories,id'], + 'description' => ['sometimes', 'string'], + 'description_iv' => ['sometimes', 'string', 'size:16'], 'notes' => ['nullable', 'string'], 'notes_iv' => ['nullable', 'string', 'size:16'], ]; @@ -24,6 +26,7 @@ class UpdateTransactionRequest extends FormRequest { return [ 'category_id.exists' => 'The selected category does not exist.', + 'description_iv.size' => 'The description IV must be exactly 16 characters.', 'notes_iv.size' => 'The notes IV must be exactly 16 characters.', ]; } diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 7cd4ac5a..cfd65d53 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\TransactionSource; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -24,6 +25,7 @@ class Transaction extends Model 'currency_code', 'notes', 'notes_iv', + 'source', ]; protected function casts(): array @@ -31,6 +33,7 @@ class Transaction extends Model return [ 'transaction_date' => 'date', 'amount' => 'integer', + 'source' => TransactionSource::class, ]; } diff --git a/database/factories/TransactionFactory.php b/database/factories/TransactionFactory.php index 18b2199f..edbf05f9 100644 --- a/database/factories/TransactionFactory.php +++ b/database/factories/TransactionFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\TransactionSource; use App\Models\Account; use App\Models\Category; use App\Models\User; @@ -30,6 +31,14 @@ class TransactionFactory extends Factory 'currency_code' => fake()->randomElement(['USD', 'EUR', 'GBP', 'JPY']), 'notes' => fake()->optional()->paragraph(), 'notes_iv' => fake()->optional()->regexify('[A-Za-z0-9]{16}'), + 'source' => TransactionSource::ManuallyCreated, ]; } + + public function imported(): static + { + return $this->state(fn (array $attributes) => [ + 'source' => TransactionSource::Imported, + ]); + } } diff --git a/database/migrations/2025_11_28_104227_add_source_to_transactions_table.php b/database/migrations/2025_11_28_104227_add_source_to_transactions_table.php new file mode 100644 index 00000000..36438149 --- /dev/null +++ b/database/migrations/2025_11_28_104227_add_source_to_transactions_table.php @@ -0,0 +1,28 @@ +string('source')->default('imported')->after('notes_iv'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropColumn('source'); + }); + } +}; diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index 2a9b75d7..0dcc339c 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -222,6 +222,11 @@ export function EditTransactionDialog({ toast.error('Date is required'); return; } + } else if (mode === 'edit' && transaction?.source === 'manually_created') { + if (!description.trim()) { + toast.error('Description is required'); + return; + } } setIsSubmitting(true); @@ -278,6 +283,7 @@ export function EditTransactionDialog({ currency_code: selectedAccount.currency_code, notes: encryptedNotes, notes_iv: notesIv, + source: 'manually_created' as const, }); const updatedCategory = finalCategoryId @@ -311,6 +317,7 @@ export function EditTransactionDialog({ const selectedCategoryId = categoryId === 'null' ? null : categoryId; const trimmedNotes = notes.trim(); + const trimmedDescription = description.trim(); let encryptedNotes: string | null = null; let notesIv: string | null = null; @@ -321,11 +328,28 @@ export function EditTransactionDialog({ notesIv = encrypted.iv; } - await transactionSyncService.update(transaction.id, { + const updateData: { + category_id: string | null; + notes: string | null; + notes_iv: string | null; + description?: string; + description_iv?: string; + } = { category_id: selectedCategoryId, notes: encryptedNotes, notes_iv: notesIv, - }); + }; + + let finalDecryptedDescription = transaction.decryptedDescription; + + if (transaction.source === 'manually_created' && trimmedDescription) { + const encryptedDescription = await encrypt(trimmedDescription, key); + updateData.description = encryptedDescription.encrypted; + updateData.description_iv = encryptedDescription.iv; + finalDecryptedDescription = trimmedDescription; + } + + await transactionSyncService.update(transaction.id, updateData); const updatedRecord = await transactionSyncService.getById( transaction.id, @@ -340,6 +364,9 @@ export function EditTransactionDialog({ ...transaction, category_id: selectedCategoryId, category: updatedCategory, + decryptedDescription: finalDecryptedDescription, + description: updateData.description ?? transaction.description, + description_iv: updateData.description_iv ?? transaction.description_iv, decryptedNotes: trimmedNotes || null, notes: encryptedNotes, notes_iv: notesIv, @@ -431,14 +458,14 @@ export function EditTransactionDialog({ - {mode === 'create' ? ( + {mode === 'create' || (mode === 'edit' && transaction?.source === 'manually_created') ? ( ) : ( -
- {transaction?.decryptedDescription} +
+ +

+ This transaction was imported from a file. The description cannot be modified. +

)}
diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index d5bf48c4..5c053d6a 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -380,6 +380,7 @@ export function ImportTransactionsDrawer({ currency_code: selectedAccount.currency_code, notes: notes, notes_iv: notesIv, + source: 'imported' as const, }; const createdTransaction = diff --git a/resources/js/types/transaction.ts b/resources/js/types/transaction.ts index 63efb06a..f83d805e 100644 --- a/resources/js/types/transaction.ts +++ b/resources/js/types/transaction.ts @@ -2,6 +2,8 @@ import { type Account, type Bank } from './account'; import { type Category } from './category'; import { UUID } from './uuid'; +export type TransactionSource = 'manually_created' | 'imported'; + export interface Transaction { id: UUID; user_id: UUID; @@ -14,6 +16,7 @@ export interface Transaction { currency_code: string; notes: string | null; notes_iv: string | null; + source: TransactionSource; created_at: string; updated_at: string; } diff --git a/tests/Feature/Sync/TransactionSyncTest.php b/tests/Feature/Sync/TransactionSyncTest.php index 248a45e8..b5d57655 100644 --- a/tests/Feature/Sync/TransactionSyncTest.php +++ b/tests/Feature/Sync/TransactionSyncTest.php @@ -83,6 +83,7 @@ it('can create a transaction', function () { 'currency_code' => 'USD', 'notes' => null, 'notes_iv' => null, + 'source' => 'manually_created', ]; $response = $this->actingAs($user)->postJson('/api/sync/transactions', $transactionData); @@ -99,6 +100,7 @@ it('can create a transaction', function () { 'transaction_date', 'amount', 'currency_code', + 'source', 'created_at', 'updated_at', ], @@ -110,6 +112,7 @@ it('can create a transaction', function () { 'category_id' => $category->id, 'description' => 'encrypted_description', 'amount' => 10050, + 'source' => 'manually_created', ]); }); @@ -129,6 +132,7 @@ it('can create a transaction with a UUID', function () { 'currency_code' => 'USD', 'notes' => null, 'notes_iv' => null, + 'source' => 'imported', ]; $response = $this->actingAs($user)->postJson('/api/sync/transactions', $transactionData); @@ -139,6 +143,7 @@ it('can create a transaction with a UUID', function () { $this->assertDatabaseHas('transactions', [ 'id' => $uuid, 'user_id' => $user->id, + 'source' => 'imported', ]); }); @@ -148,7 +153,7 @@ it('validates required fields when creating a transaction', function () { $response = $this->actingAs($user)->postJson('/api/sync/transactions', []); $response->assertUnprocessable() - ->assertJsonValidationErrors(['account_id', 'description', 'description_iv', 'transaction_date', 'amount', 'currency_code']); + ->assertJsonValidationErrors(['account_id', 'description', 'description_iv', 'transaction_date', 'amount', 'currency_code', 'source']); }); it('can update a transaction', function () { @@ -169,6 +174,7 @@ it('can update a transaction', function () { 'currency_code' => $transaction->currency_code, 'notes' => null, 'notes_iv' => null, + 'source' => 'manually_created', ]; $response = $this->actingAs($user)->patchJson("/api/sync/transactions/{$transaction->id}", $updateData); @@ -200,6 +206,7 @@ it('cannot update another user transaction', function () { 'currency_code' => $transaction->currency_code, 'notes' => null, 'notes_iv' => null, + 'source' => 'manually_created', ]; $response = $this->actingAs($user)->patchJson("/api/sync/transactions/{$transaction->id}", $updateData); diff --git a/tests/Feature/TransactionTest.php b/tests/Feature/TransactionTest.php index 34262f63..c05eaab8 100644 --- a/tests/Feature/TransactionTest.php +++ b/tests/Feature/TransactionTest.php @@ -230,6 +230,7 @@ test('users can create a new transaction', function () { 'currency_code' => 'USD', 'notes' => 'encrypted_notes', 'notes_iv' => str_repeat('n', 16), + 'source' => 'manually_created', ]; $response = actingAs($user)->postJson(route('transactions.store'), $transactionData); @@ -248,6 +249,7 @@ test('users can create a new transaction', function () { 'currency_code', 'notes', 'notes_iv', + 'source', 'created_at', 'updated_at', ], @@ -260,6 +262,7 @@ test('users can create a new transaction', function () { 'description' => 'encrypted_description', 'amount' => 15050, 'currency_code' => 'USD', + 'source' => 'manually_created', ]); }); @@ -275,6 +278,7 @@ test('users can create a transaction without category', function () { 'transaction_date' => '2025-11-11', 'amount' => 7525, 'currency_code' => 'EUR', + 'source' => 'manually_created', ]; $response = actingAs($user)->postJson(route('transactions.store'), $transactionData); @@ -300,6 +304,7 @@ test('users can create a transaction without notes', function () { 'transaction_date' => '2025-11-11', 'amount' => 10000, 'currency_code' => 'USD', + 'source' => 'imported', ]; $response = actingAs($user)->postJson(route('transactions.store'), $transactionData);