feat(transactions): allow editing all fields of manual transactions

Manually created transactions can now edit every field (account, date,
amount, description) after creation, not just category/notes/labels.
Imported/bank transactions keep amount, date, account, currency and
description locked to their source data.

- UpdateTransactionRequest validates amount/date/account/currency only
  for manually_created transactions, so the fields are dropped for
  imported ones.
- On edit, the manual account balance is moved to match a changed
  amount/date/account, opt-in via the same update_balance flag as
  create/delete; connected accounts are skipped. ManualBalanceAdjuster
  gains a shared adjust() primitive and reverseCreatedTransaction().
- The edit dialog unifies create/edit editability behind canEditAllFields
  and shows the account/date/amount inputs (and the update-balance
  checkbox) when editing a manual transaction.
This commit is contained in:
Víctor Falcón 2026-07-16 08:38:56 +02:00
parent c7719fe5ad
commit 148aee0b63
8 changed files with 419 additions and 63 deletions

View File

@ -213,7 +213,7 @@ class TransactionController extends Controller
], 201);
}
public function update(UpdateTransactionRequest $request, Transaction $transaction): JsonResponse
public function update(UpdateTransactionRequest $request, Transaction $transaction, ManualBalanceAdjuster $balanceAdjuster): JsonResponse
{
$this->authorize('update', $transaction);
@ -238,6 +238,10 @@ class TransactionController extends Controller
}
}
// Snapshot the pre-edit account/date/amount before filling, so a manual
// account balance can be moved off the old values if the edit changes them.
$originalSnapshot = clone $transaction;
// Update attributes directly without firing events yet
if (! empty($data)) {
$transaction->fill($data);
@ -260,6 +264,18 @@ class TransactionController extends Controller
$transaction->save();
}
// Move the manual account balance to match an edited amount/date/account.
// ponytail: like create/delete, this trusts the opt-in flag and nudges a
// single dated snapshot — it does not cascade to later snapshots and keeps
// no record of whether creation adjusted the balance. So it is exact for
// the common case (recent transaction, flag used consistently) and can
// drift otherwise; upgrade to a transaction-derived balance if that drift
// ever matters. Connected accounts are skipped inside the adjuster.
if ($request->boolean('update_balance') && $transaction->wasChanged(['amount', 'transaction_date', 'account_id'])) {
$balanceAdjuster->reverseCreatedTransaction($originalSnapshot);
$balanceAdjuster->applyCreatedTransaction($transaction->load('account'));
}
return response()->json([
'data' => $transaction->fresh()->load('labels'),
'learned_rule' => $learnedRule === null ? null : [

View File

@ -2,7 +2,9 @@
namespace App\Http\Requests;
use App\Enums\TransactionSource;
use App\Http\Requests\Concerns\ValidatesUserOwnedResources;
use App\Models\Transaction;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTransactionRequest extends FormRequest
@ -16,7 +18,7 @@ class UpdateTransactionRequest extends FormRequest
public function rules(): array
{
return [
$rules = [
'category_id' => ['nullable', $this->userOwned('categories')],
'description' => ['sometimes', 'string'],
'description_iv' => ['nullable', 'string', 'size:16'],
@ -27,6 +29,20 @@ class UpdateTransactionRequest extends FormRequest
'label_ids' => ['nullable', 'array'],
'label_ids.*' => ['required', 'string', 'uuid', $this->userOwned('labels')],
];
// Manually created transactions can edit every field after creation.
// Imported ones keep amount, date, account and currency locked to the
// source data, so those keys are only validated (and thus persisted)
// for manual transactions.
$transaction = $this->route('transaction');
if ($transaction instanceof Transaction && $transaction->source === TransactionSource::ManuallyCreated) {
$rules['account_id'] = ['sometimes', $this->userOwned('accounts')];
$rules['transaction_date'] = ['sometimes', 'date'];
$rules['amount'] = ['sometimes', 'integer'];
$rules['currency_code'] = ['sometimes', 'string', 'size:3'];
}
return $rules;
}
public function messages(): array

View File

@ -17,28 +17,7 @@ class ManualBalanceAdjuster
*/
public function reverseDeletedTransaction(Transaction $transaction): void
{
$account = $transaction->account;
if ($account === null || $account->isConnected()) {
return;
}
$today = Carbon::now()->toDateString();
$currentBalance = $account->balances()
->where('balance_date', '<=', $today)
->orderByDesc('balance_date')
->value('balance') ?? 0;
AccountBalance::updateOrCreate(
[
'account_id' => $account->id,
'balance_date' => $today,
],
[
'balance' => $currentBalance - $transaction->amount,
],
);
$this->adjust($transaction, Carbon::now()->toDateString(), -$transaction->amount);
}
/**
@ -50,6 +29,25 @@ class ManualBalanceAdjuster
* skipped because their balances come from bank sync.
*/
public function applyCreatedTransaction(Transaction $transaction): void
{
$this->adjust($transaction, $transaction->transaction_date->toDateString(), $transaction->amount);
}
/**
* Reverse a transaction's effect on its manual account's balance on the
* transaction's own date. Pair with applyCreatedTransaction to move the
* balance when an existing manual transaction is edited.
*/
public function reverseCreatedTransaction(Transaction $transaction): void
{
$this->adjust($transaction, $transaction->transaction_date->toDateString(), -$transaction->amount);
}
/**
* Nudge the manual account's stored balance on a given date by a delta.
* Connected accounts are skipped because their balances come from bank sync.
*/
private function adjust(Transaction $transaction, string $balanceDate, int $delta): void
{
$account = $transaction->account;
@ -57,20 +55,18 @@ class ManualBalanceAdjuster
return;
}
$transactionDate = $transaction->transaction_date->toDateString();
$baseBalance = $account->balances()
->where('balance_date', '<=', $transactionDate)
->where('balance_date', '<=', $balanceDate)
->orderByDesc('balance_date')
->value('balance') ?? 0;
AccountBalance::updateOrCreate(
[
'account_id' => $account->id,
'balance_date' => $transactionDate,
'balance_date' => $balanceDate,
],
[
'balance' => $baseBalance + $transaction->amount,
'balance' => $baseBalance + $delta,
],
);
}

View File

@ -1745,6 +1745,7 @@
"Update the owed amount record.": "Actualiza el registro de monto adeudado.",
"Update the rule to automatically categorize transactions and add labels.": "Actualiza la regla para categorizar automáticamente transacciones y agregar etiquetas.",
"Update the rule to automatically categorize transactions.": "Actualiza la regla para categorizar transacciones automáticamente.",
"Update this transaction.": "Actualiza esta transacción.",
"Update your account's appearance settings": "Actualiza la configuración de apariencia de tu cuenta",
"Update your budget settings. To change the allocated amount or tracking, use the budget page directly.": "Actualiza la configuración de tu presupuesto. Para cambiar el valor asignado o el seguimiento, usa la página de presupuesto directamente.",
"Update your budget settings. To change the allocated\\n amount or tracking, use the budget page directly.": "Actualiza la configuración de tu presupuesto. Para cambiar el monto asignado o el seguimiento, usa la página de presupuesto directamente.",

View File

@ -229,6 +229,93 @@ describe('EditTransactionDialog', () => {
expect(screen.getByRole('checkbox')).toBeChecked();
});
it('lets you edit every field of a manually created transaction', () => {
render(
<EditTransactionDialog
transaction={{
id: 'tx-manual',
user_id: 'user-1',
account_id: 'account-1',
category_id: null,
description: 'Groceries',
description_iv: null,
transaction_date: '2026-05-27',
amount: -1200,
currency_code: 'EUR',
notes: null,
notes_iv: null,
creditor_name: null,
debtor_name: null,
source: 'manually_created',
created_at: '2026-05-27T00:00:00Z',
updated_at: '2026-05-27T00:00:00Z',
decryptedDescription: 'Groceries',
decryptedNotes: null,
label_ids: [],
}}
categories={[]}
accounts={[checkingAccount]}
banks={[]}
labels={[]}
open
onOpenChange={vi.fn()}
onSuccess={vi.fn()}
mode="edit"
/>,
);
// Amount, date and description render as editable inputs, not read-only text.
expect(screen.getByPlaceholderText('25.00')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Transaction description'),
).toBeInTheDocument();
expect(document.querySelector('input[type="date"]')).toBeInTheDocument();
});
it('keeps amount and description read-only for an imported transaction', () => {
render(
<EditTransactionDialog
transaction={{
id: 'tx-imported',
user_id: 'user-1',
account_id: 'account-1',
category_id: null,
description: 'Card payment',
description_iv: null,
transaction_date: '2026-05-27',
amount: -1200,
currency_code: 'EUR',
notes: null,
notes_iv: null,
creditor_name: null,
debtor_name: null,
source: 'imported',
created_at: '2026-05-27T00:00:00Z',
updated_at: '2026-05-27T00:00:00Z',
decryptedDescription: 'Card payment',
decryptedNotes: null,
label_ids: [],
}}
categories={[]}
accounts={[checkingAccount]}
banks={[]}
labels={[]}
open
onOpenChange={vi.fn()}
onSuccess={vi.fn()}
mode="edit"
/>,
);
expect(screen.queryByPlaceholderText('25.00')).not.toBeInTheDocument();
expect(
screen.queryByPlaceholderText('Transaction description'),
).not.toBeInTheDocument();
expect(
document.querySelector('input[type="date"]'),
).not.toBeInTheDocument();
});
it('hides "update account balance" for a connected account', () => {
const connectedAccount = {
...checkingAccount,

View File

@ -105,6 +105,11 @@ export function EditTransactionDialog({
return true;
});
// Manually created transactions can edit every field (account, date, amount,
// description) both on creation and afterwards. Imported ones keep those locked.
const canEditAllFields =
mode === 'create' || transaction?.source === 'manually_created';
useEffect(() => {
if (mode === 'edit' && transaction) {
setTransactionDate(transaction.transaction_date);
@ -135,7 +140,7 @@ export function EditTransactionDialog({
}, [mode, transaction, open, accounts, initialAccountId]);
useEffect(() => {
if (!open || mode !== 'create') return;
if (!open || !canEditAllFields) return;
async function decryptAccountNames() {
const keyString = getStoredKey();
@ -185,7 +190,7 @@ export function EditTransactionDialog({
}
decryptAccountNames();
}, [open, mode, accounts]);
}, [open, canEditAllFields, accounts]);
async function checkAndApplyAutomationRules() {
if (mode !== 'create' || automationRules.length === 0) {
@ -273,7 +278,7 @@ export function EditTransactionDialog({
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (mode === 'create') {
if (canEditAllFields) {
if (!description.trim()) {
toast.error(__('Description is required'));
return;
@ -290,14 +295,6 @@ 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);
@ -423,6 +420,10 @@ export function EditTransactionDialog({
description?: string;
description_iv?: string | null;
label_ids?: string[];
amount?: number;
transaction_date?: string;
account_id?: string;
currency_code?: string;
} = {
category_id: selectedCategoryId,
notes: encryptedNotes,
@ -433,18 +434,34 @@ export function EditTransactionDialog({
let finalDecryptedDescription =
transaction.decryptedDescription;
if (
transaction.source === 'manually_created' &&
trimmedDescription
) {
const editedAccount = accounts.find(
(acc) => acc.id === accountId,
);
const editedCurrencyCode =
editedAccount?.currency_code ?? transaction.currency_code;
if (canEditAllFields) {
updateData.description = trimmedDescription;
updateData.description_iv = null;
finalDecryptedDescription = trimmedDescription;
updateData.amount = amount;
updateData.transaction_date = transactionDate;
updateData.account_id = accountId;
updateData.currency_code = editedCurrencyCode;
}
const result = await transactionSyncService.update(
transaction.id,
updateData,
{
// Gate on the transaction being manual, not on the target
// account: the backend adjuster skips connected accounts
// per-account, so this still reverses the old manual
// account when the edit moves it onto a connected one.
updateBalance: canEditAllFields
? updateAccountBalance
: false,
},
);
const updatedRecord = await transactionSyncService.getById(
@ -476,6 +493,20 @@ export function EditTransactionDialog({
labels: selectedLabels,
updated_at:
updatedRecord?.updated_at ?? transaction.updated_at,
...(canEditAllFields
? {
amount,
transaction_date: transactionDate,
account_id: accountId,
currency_code: editedCurrencyCode,
account: editedAccount ?? transaction.account,
bank: editedAccount?.bank?.id
? banks.find(
(b) => b.id === editedAccount.bank?.id,
)
: transaction.bank,
}
: {}),
};
toast.success(__('Transaction updated successfully'));
@ -549,15 +580,17 @@ export function EditTransactionDialog({
<DialogDescription>
{mode === 'create'
? __('Create a new transaction.')
: __(
'Update the category and notes for this transaction.',
)}
: canEditAllFields
? __('Update this transaction.')
: __(
'Update the category and notes for this transaction.',
)}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
{mode === 'create' && (
{canEditAllFields && (
<div className="space-y-2">
<FormLabel htmlFor="account">
{__('Account')}
@ -597,14 +630,14 @@ export function EditTransactionDialog({
<FormLabel
htmlFor="date"
className={
mode === 'edit'
? 'text-sm text-muted-foreground'
: ''
canEditAllFields
? ''
: 'text-sm text-muted-foreground'
}
>
{__('Date')}
</FormLabel>
{mode === 'create' ? (
{canEditAllFields ? (
<Input
id="date"
type="date"
@ -652,17 +685,14 @@ export function EditTransactionDialog({
<FormLabel
htmlFor="description"
className={
mode === 'edit' &&
transaction?.source === 'imported'
? 'text-sm text-muted-foreground'
: ''
canEditAllFields
? ''
: 'text-sm text-muted-foreground'
}
>
{__('Description')}
</FormLabel>
{mode === 'create' ||
(mode === 'edit' &&
transaction?.source === 'manually_created') ? (
{canEditAllFields ? (
<Textarea
id="description"
value={description}
@ -736,14 +766,14 @@ export function EditTransactionDialog({
<FormLabel
htmlFor="amount"
className={
mode === 'edit'
? 'text-sm text-muted-foreground'
: ''
canEditAllFields
? ''
: 'text-sm text-muted-foreground'
}
>
{__('Amount')}
</FormLabel>
{mode === 'create' ? (
{canEditAllFields ? (
<>
<AmountInput
id="amount"

View File

@ -102,12 +102,14 @@ class TransactionSyncService {
async update(
id: string,
data: TransactionUpdateData,
options?: { updateBalance?: boolean },
): Promise<UpdatedTransaction> {
const { label_ids, ...transactionData } = data;
const response = await axios.patch(`/transactions/${id}`, {
...transactionData,
label_ids,
...(options?.updateBalance ? { update_balance: true } : {}),
});
const serverData = response.data.data || response.data;

View File

@ -202,6 +202,66 @@ test('notes_iv must be exactly 16 characters', function () {
$response->assertJsonValidationErrors(['notes_iv']);
});
test('users can edit the amount, date and account of a manually created transaction', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id, 'currency_code' => 'USD']);
$otherAccount = Account::factory()->create(['user_id' => $user->id, 'currency_code' => 'EUR']);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => 2500,
'transaction_date' => '2026-01-01',
'currency_code' => 'USD',
'source' => 'manually_created',
]);
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
'description' => 'updated_description',
'amount' => 9900,
'transaction_date' => '2026-02-15',
'account_id' => $otherAccount->id,
'currency_code' => 'EUR',
]);
$response->assertSuccessful();
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'description' => 'updated_description',
'amount' => 9900,
'transaction_date' => '2026-02-15',
'account_id' => $otherAccount->id,
'currency_code' => 'EUR',
]);
});
test('users cannot edit the amount, date or account of an imported transaction', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$otherAccount = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->imported()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => 2500,
'transaction_date' => '2026-01-01',
]);
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
'amount' => 9900,
'transaction_date' => '2026-02-15',
'account_id' => $otherAccount->id,
]);
$response->assertSuccessful();
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'amount' => 2500,
'transaction_date' => '2026-01-01',
'account_id' => $account->id,
]);
});
test('users can soft delete their own transactions', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
@ -485,6 +545,154 @@ test('creating a connected account transaction never changes the balance', funct
]);
});
test('editing a manual transaction amount moves the balance by the delta when requested', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
// Balance already reflects the transaction's original 2500 amount (100000 base + 2500).
$account->balances()->create([
'balance_date' => '2025-11-11',
'balance' => 102500,
]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => 2500,
'transaction_date' => '2025-11-11',
'source' => 'manually_created',
]);
actingAs($user)->patchJson(route('transactions.update', $transaction), [
'amount' => 5000,
'update_balance' => true,
])->assertSuccessful();
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => '2025-11-11',
'balance' => 105000,
]);
});
test('editing a manual transaction amount does not change the balance when not requested', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$account->balances()->create([
'balance_date' => '2025-11-11',
'balance' => 102500,
]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => 2500,
'transaction_date' => '2025-11-11',
'source' => 'manually_created',
]);
actingAs($user)->patchJson(route('transactions.update', $transaction), [
'amount' => 5000,
])->assertSuccessful();
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => '2025-11-11',
'balance' => 102500,
]);
});
test('moving a manual transaction between accounts reverses the old balance and credits the new one', function () {
$user = User::factory()->onboarded()->create();
$fromAccount = Account::factory()->create(['user_id' => $user->id]);
$toAccount = Account::factory()->create(['user_id' => $user->id]);
// Origin balance embeds the transaction's 2500; destination starts flat.
$fromAccount->balances()->create(['balance_date' => '2025-11-11', 'balance' => 102500]);
$toAccount->balances()->create(['balance_date' => '2025-11-11', 'balance' => 50000]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $fromAccount->id,
'amount' => 2500,
'transaction_date' => '2025-11-11',
'source' => 'manually_created',
]);
actingAs($user)->patchJson(route('transactions.update', $transaction), [
'account_id' => $toAccount->id,
'update_balance' => true,
])->assertSuccessful();
$this->assertDatabaseHas('account_balances', [
'account_id' => $fromAccount->id,
'balance_date' => '2025-11-11',
'balance' => 100000,
]);
$this->assertDatabaseHas('account_balances', [
'account_id' => $toAccount->id,
'balance_date' => '2025-11-11',
'balance' => 52500,
]);
});
test('editing only the currency of a manual transaction does not change the balance', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$account->balances()->create(['balance_date' => '2025-11-11', 'balance' => 102500]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => 2500,
'transaction_date' => '2025-11-11',
'currency_code' => 'USD',
'source' => 'manually_created',
]);
actingAs($user)->patchJson(route('transactions.update', $transaction), [
'currency_code' => 'EUR',
'update_balance' => true,
])->assertSuccessful();
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => '2025-11-11',
'balance' => 102500,
]);
});
test('editing a connected account transaction never changes the balance', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->connected()->create(['user_id' => $user->id]);
$account->balances()->create([
'balance_date' => '2025-11-11',
'balance' => 100000,
]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => 2500,
'transaction_date' => '2025-11-11',
'source' => 'manually_created',
]);
actingAs($user)->patchJson(route('transactions.update', $transaction), [
'amount' => 5000,
'update_balance' => true,
])->assertSuccessful();
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => '2025-11-11',
'balance' => 100000,
]);
});
test('transactions index page passes user categories', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->create();