fix(balances): allow saving a zero balance (#664)

## 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

<!-- PLACEHOLDER: drag the video here -->

_Video to attach: `~/Downloads/allow-zero-balances-demo.mp4`_
This commit is contained in:
Víctor Falcón 2026-07-10 15:02:38 +02:00 committed by GitHub
parent 6f72c43cce
commit ca2e5c09b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 41 additions and 11 deletions

View File

@ -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",

View File

@ -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",

View File

@ -441,7 +441,6 @@ export function BalancesModal({
value={editAmount}
onChange={setEditAmount}
currencyCode={account.currency_code}
required
/>
</div>

View File

@ -199,7 +199,6 @@ export function UpdateBalanceDialog({
value={balance}
onChange={setBalance}
currencyCode={account.currency_code}
required
/>
)}
</div>

View File

@ -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
/>
</div>

View File

@ -428,7 +428,6 @@ export function ImportStepMapping({
value={referenceBalance ?? 0}
onChange={onReferenceBalanceChange}
currencyCode={currencyCode}
required
/>
{referenceBalancePrefilled && (
<p className="text-xs text-muted-foreground">

View File

@ -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();