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:
parent
33858a04db
commit
8bee848fe3
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum TransactionSource: string
|
||||
{
|
||||
case ManuallyCreated = 'manually_created';
|
||||
case Imported = 'imported';
|
||||
}
|
||||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -380,6 +380,7 @@ export function ImportTransactionsDrawer({
|
|||
currency_code: selectedAccount.currency_code,
|
||||
notes: notes,
|
||||
notes_iv: notesIv,
|
||||
source: 'imported' as const,
|
||||
};
|
||||
|
||||
const createdTransaction =
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue