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') ? ( ) : ( -
+ This transaction was imported from a file. The description cannot be modified. +