diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php
index 3df66dec..da086fc8 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:
+ // strip the pre-edit contribution (exactly as a deletion would) and apply
+ // the new one (exactly as a creation would), both cascading forward.
+ // ponytail: like create/delete, this trusts the opt-in flag and keeps no
+ // record of whether creation adjusted the balance, so mixing the flag
+ // across create and edit can drift; a transaction-derived balance would
+ // remove that trust. Connected accounts are skipped inside the adjuster.
+ if ($request->boolean('update_balance') && $transaction->wasChanged(['amount', 'transaction_date', 'account_id'])) {
+ $balanceAdjuster->reverseDeletedTransaction($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/Models/Transaction.php b/app/Models/Transaction.php
index fb9dccdd..84d3b169 100644
--- a/app/Models/Transaction.php
+++ b/app/Models/Transaction.php
@@ -26,6 +26,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property Carbon $transaction_date
* @property int|float $total_amount
+ * @property TransactionSource $source
* @property ?CategorySource $category_source
* @property ?float $ai_confidence
* @property ?string $categorized_by_rule_id
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..c287bb16 100644
--- a/resources/js/components/transactions/edit-transaction-dialog.test.tsx
+++ b/resources/js/components/transactions/edit-transaction-dialog.test.tsx
@@ -229,6 +229,95 @@ describe('EditTransactionDialog', () => {
expect(screen.getByRole('checkbox')).toBeChecked();
});
+ it('lets you edit every field of a manually created transaction', () => {
+ render(
+