From ca2e5c09b3e83eee06dc0a7b6c81d018fcd81175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 10 Jul 2026 15:02:38 +0200 Subject: [PATCH] fix(balances): allow saving a zero balance (#664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why A balance of exactly **0** could not be saved. Zero is a perfectly valid balance for any account (an emptied wallet, a paid-off loan, a closed position), so the app was rejecting legitimate input. ## Root cause The shared `AmountInput` renders a numeric value of `0` as an **empty string** (so the field shows the `0.00` placeholder instead of a literal "0.00"). Several balance forms combined that with the native HTML `required` attribute — which then rejected the *only* value that renders empty: `0`. `required` on this input never guarded against a missing value (the state is always numeric; empty == 0), it only ever blocked zero. The backend already accepted `0` (`required|integer`, and Laravel's `required` treats integer `0` as present), so this was purely a frontend constraint. ## Changes - Drop `required` from the balance `AmountInput` in the **Update balance** dialog and the **Balance history** edit modal. - Remove the explicit `balanceInCents === 0` guard (and `required`) in the onboarding **Set balance** step. - Extend the same fix to the CSV-import **reference balance** field (identical root cause; the compute path already gates on `referenceBalance !== null`, so an explicit `0` is a valid anchor). - Remove the now-orphaned `"Please enter a balance"` translation from `es.json` / `fr.json`. - Add feature tests asserting a zero balance can be stored and set as the current balance. Transaction and budget amount inputs keep `required` — `0` is not a meaningful value there, so they are intentionally untouched. ## QA - **Onboarding balance step**: saving with an empty/zero field now advances instead of showing "Please enter a balance". ✅ - **Update balance dialog**: set €1,000.00, then overwrote it with €0.00 → dialog closed and the record persisted. Verified in the DB (`account_balances.balance = 0`). ✅ - Backend contract locked by new tests in `AccountBalanceControllerTest`. ## Demo _Video to attach: `~/Downloads/allow-zero-balances-demo.mp4`_ --- lang/es.json | 1 - lang/fr.json | 1 - .../js/components/accounts/balances-modal.tsx | 1 - .../accounts/update-balance-dialog.tsx | 1 - .../onboarding/step-import-balances.tsx | 6 --- .../transactions/import-step-mapping.tsx | 1 - .../Feature/AccountBalanceControllerTest.php | 41 +++++++++++++++++++ 7 files changed, 41 insertions(+), 11 deletions(-) diff --git a/lang/es.json b/lang/es.json index 9d71af0c..9845cae4 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1240,7 +1240,6 @@ "Pink": "Rosa", "Platform": "Plataforma", "Please describe the problem and what you were doing when it happened. Keep the details below — they help us debug.": "Describe el problema y qué estabas haciendo cuando ocurrió. Conserva los detalles de abajo: nos ayudan a depurar.", - "Please enter a balance": "Por favor, ingresa un balance", "Please enter a bank name.": "Por favor, ingresa un nombre de banco.", "Please enter an account name.": "Por favor, ingresa un nombre de cuenta.", "Please enter your new password below": "Por favor ingresa tu nueva contraseña a continuación", diff --git a/lang/fr.json b/lang/fr.json index 4413b29e..136cf517 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1199,7 +1199,6 @@ "Pink": "Rose", "Platform": "Plate-forme", "Please describe the problem and what you were doing when it happened. Keep the details below — they help us debug.": "Décrivez le problème et ce que vous faisiez lorsqu'il s'est produit. Conservez les détails ci-dessous : ils nous aident à déboguer.", - "Please enter a balance": "Veuillez saisir un solde", "Please enter a bank name.": "Veuillez saisir un nom de banque.", "Please enter an account name.": "Veuillez saisir un nom de compte.", "Please enter your new password below": "Veuillez entrer votre nouveau mot de passe ci-dessous", diff --git a/resources/js/components/accounts/balances-modal.tsx b/resources/js/components/accounts/balances-modal.tsx index f28a304a..f3367887 100644 --- a/resources/js/components/accounts/balances-modal.tsx +++ b/resources/js/components/accounts/balances-modal.tsx @@ -441,7 +441,6 @@ export function BalancesModal({ value={editAmount} onChange={setEditAmount} currencyCode={account.currency_code} - required /> diff --git a/resources/js/components/accounts/update-balance-dialog.tsx b/resources/js/components/accounts/update-balance-dialog.tsx index d2e6b5f5..45e5eee6 100644 --- a/resources/js/components/accounts/update-balance-dialog.tsx +++ b/resources/js/components/accounts/update-balance-dialog.tsx @@ -199,7 +199,6 @@ export function UpdateBalanceDialog({ value={balance} onChange={setBalance} currencyCode={account.currency_code} - required /> )} diff --git a/resources/js/components/onboarding/step-import-balances.tsx b/resources/js/components/onboarding/step-import-balances.tsx index 0e615837..520bb421 100644 --- a/resources/js/components/onboarding/step-import-balances.tsx +++ b/resources/js/components/onboarding/step-import-balances.tsx @@ -24,11 +24,6 @@ export function StepImportBalances({ e.preventDefault(); setError(null); - if (balanceInCents === 0) { - setError(__('Please enter a balance')); - return; - } - setIsSubmitting(true); try { @@ -100,7 +95,6 @@ export function StepImportBalances({ onChange={setBalanceInCents} currencyCode={account?.currencyCode || 'USD'} disabled={isSubmitting} - required /> diff --git a/resources/js/components/transactions/import-step-mapping.tsx b/resources/js/components/transactions/import-step-mapping.tsx index 81970cd9..2b784af0 100644 --- a/resources/js/components/transactions/import-step-mapping.tsx +++ b/resources/js/components/transactions/import-step-mapping.tsx @@ -428,7 +428,6 @@ export function ImportStepMapping({ value={referenceBalance ?? 0} onChange={onReferenceBalanceChange} currencyCode={currencyCode} - required /> {referenceBalancePrefilled && (

diff --git a/tests/Feature/AccountBalanceControllerTest.php b/tests/Feature/AccountBalanceControllerTest.php index 550a3ca1..819d76ee 100644 --- a/tests/Feature/AccountBalanceControllerTest.php +++ b/tests/Feature/AccountBalanceControllerTest.php @@ -280,6 +280,47 @@ it('can store balance without invested_amount', function () { ]); }); +it('can store a zero balance', function () { + $user = User::factory()->create(); + $account = Account::factory()->for($user)->create(); + + $response = $this->actingAs($user)->postJson("/api/accounts/{$account->id}/balances", [ + 'balance' => 0, + 'balance_date' => now()->subDays(5)->toDateString(), + ]); + + $response->assertStatus(201) + ->assertJsonPath('data.balance', 0); + + $this->assertDatabaseHas('account_balances', [ + 'account_id' => $account->id, + 'balance' => 0, + ]); +}); + +it('can update the current balance to zero', function () { + $user = User::factory()->create(); + $account = Account::factory()->for($user)->create(); + + AccountBalance::factory()->for($account)->create([ + 'balance_date' => now()->toDateString(), + 'balance' => 100000, + ]); + + $response = $this->actingAs($user)->putJson("/api/accounts/{$account->id}/balance/current", [ + 'balance' => 0, + ]); + + $response->assertSuccessful() + ->assertJsonPath('data.balance', 0); + + $this->assertDatabaseHas('account_balances', [ + 'account_id' => $account->id, + 'balance_date' => now()->toDateString(), + 'balance' => 0, + ]); +}); + it('validates store balance must be an integer', function () { $user = User::factory()->create(); $account = Account::factory()->for($user)->create();