diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php
index 3df66dec..a2fc312b 100644
--- a/app/Http/Controllers/TransactionController.php
+++ b/app/Http/Controllers/TransactionController.php
@@ -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 : [
diff --git a/app/Http/Requests/UpdateTransactionRequest.php b/app/Http/Requests/UpdateTransactionRequest.php
index 1fca7a75..d77dde52 100644
--- a/app/Http/Requests/UpdateTransactionRequest.php
+++ b/app/Http/Requests/UpdateTransactionRequest.php
@@ -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
diff --git a/app/Services/ManualBalanceAdjuster.php b/app/Services/ManualBalanceAdjuster.php
index 758ae109..24e02091 100644
--- a/app/Services/ManualBalanceAdjuster.php
+++ b/app/Services/ManualBalanceAdjuster.php
@@ -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,
],
);
}
diff --git a/lang/es.json b/lang/es.json
index 66138f8e..8facad96 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -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.",
diff --git a/resources/js/components/transactions/edit-transaction-dialog.test.tsx b/resources/js/components/transactions/edit-transaction-dialog.test.tsx
index d4814261..d4923b0e 100644
--- a/resources/js/components/transactions/edit-transaction-dialog.test.tsx
+++ b/resources/js/components/transactions/edit-transaction-dialog.test.tsx
@@ -229,6 +229,93 @@ describe('EditTransactionDialog', () => {
expect(screen.getByRole('checkbox')).toBeChecked();
});
+ it('lets you edit every field of a manually created transaction', () => {
+ render(
+