Save transaction source (manually_created, or imported)

- Depending on the source, we allow or not to update the transaction
description.
This commit is contained in:
Víctor Falcón 2025-11-28 12:15:38 +01:00
parent 33858a04db
commit 8bee848fe3
11 changed files with 115 additions and 7 deletions

View File

@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum TransactionSource: string
{
case ManuallyCreated = 'manually_created';
case Imported = 'imported';
}

View File

@ -2,7 +2,9 @@
namespace App\Http\Requests;
use App\Enums\TransactionSource;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTransactionRequest extends FormRequest
{
@ -24,6 +26,7 @@ class StoreTransactionRequest extends FormRequest
'currency_code' => ['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.',
];
}
}

View File

@ -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.',
];
}

View File

@ -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,
];
}

View File

@ -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,
]);
}
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->string('source')->default('imported')->after('notes_iv');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropColumn('source');
});
}
};

View File

@ -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({
<Label
htmlFor="description"
className={
mode === 'edit'
mode === 'edit' && transaction?.source === 'imported'
? 'text-sm text-muted-foreground'
: ''
}
>
Description
</Label>
{mode === 'create' ? (
{mode === 'create' || (mode === 'edit' && transaction?.source === 'manually_created') ? (
<Input
id="description"
type="text"
@ -451,8 +478,17 @@ export function EditTransactionDialog({
required
/>
) : (
<div className="text-sm">
{transaction?.decryptedDescription}
<div className="space-y-1.5">
<Input
id="description"
type="text"
value={transaction?.decryptedDescription ?? ''}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
This transaction was imported from a file. The description cannot be modified.
</p>
</div>
)}
</div>

View File

@ -380,6 +380,7 @@ export function ImportTransactionsDrawer({
currency_code: selectedAccount.currency_code,
notes: notes,
notes_iv: notesIv,
source: 'imported' as const,
};
const createdTransaction =

View File

@ -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;
}

View File

@ -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);

View File

@ -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);