fix: balance adjustment silently did nothing on a decimal comma

Pressing Apply on Settings -> Adjust balance did nothing at all: no error, no
request, nothing in the API logs. The cause is in code untouched since
062ba52 (2026-07-01), so this predates the recent releases — it just needs
the right input to show itself.

The form only renders when adjustingAccount?.id === account.id, so
adjustingAccount is never null while Apply is visible. That leaves exactly
one way for the handler to return silently:

    if (!adjustingAccount || !adjustTargetBalance) return

i.e. adjustTargetBalance === "". And the field was <input type="number">,
which reports value === "" for anything the browser judges invalid —
including a decimal comma. Every amount in this app is rendered with
Intl.NumberFormat('de-DE'), so typing 1234,56 is the natural thing to do, and
it left the state empty and the guard swallowed the click.

Fixes both halves:

- The input is now type="text" inputMode="decimal", so what the user types
  actually reaches the handler.
- The guard reports "Enter a target balance, e.g. 1234.56" instead of
  returning silently. A control that does nothing when pressed is the worst
  possible failure mode.

Adds parseDecimal() alongside parseAmount() in lib/utils.ts. Balance targets
differ from expense rows: zero and negative are legitimate, so parseAmount's
n > 0 rule would have been wrong here.

parseDecimal is also stricter than the parseFloat(s.replace(',', '.')) idiom
used across the flows, in two ways that matter on a balance field:

- "1.000,50" parsed as 1 under the old idiom — a silent 1000x error. It now
  reads the rightmost separator as the decimal point, giving 1000.50, and
  handles "1,000.50" the same way.
- "12.5abc" quietly became 12.5 under parseFloat. Number() rejects it.

parseAmount is redefined on top of parseDecimal, so the batch expense grid
picks up both improvements.

Verified: tsc + vite build clean; the 60 backend tests still pass (untouched);
parser checked against .-decimal, ,-decimal, both-separator, negative, zero,
whitespace and trailing-garbage inputs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
This commit is contained in:
EislM0203andClaude Opus 5 committed 2026-08-23 14:00:07 +00:00
1 parent 43c90dcd43
commit 548d640231
2 files changed
+48 -8

No files matched your search

+37 -4
View File
@@ -10,9 +10,42 @@ export function todayLocalISO(): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} }
// Accepts both 12.40 and the German 12,40. Returns null for anything that is // Accepts both 12.40 and the German 12,40, which is what the de-DE formatting
// not a usable positive amount, so callers can report which row is wrong. // used throughout the app trains you to type. Returns null when the input is
// not a number at all. Zero and negatives are valid — a target balance or a
// payable can legitimately be either.
//
// Pair this with <input type="text" inputMode="decimal">, never
// <input type="number">: a number input silently reports value === "" for
// anything it considers invalid, including a decimal comma, so the bad input
// never reaches the parser and the caller sees an empty field instead.
export function parseDecimal(input: string): number | null {
const t = input.trim()
if (t === '') return null
const lastComma = t.lastIndexOf(',')
const lastDot = t.lastIndexOf('.')
let normalised: string
if (lastComma !== -1 && lastDot !== -1) {
// Both separators present, so the rightmost one is the decimal point and
// the other is thousands grouping. Without this, "1.000,50" would parse
// as 1 — a silent 1000x error on a balance field.
normalised = lastComma > lastDot
? t.replace(/\./g, '').replace(',', '.')
: t.replace(/,/g, '')
} else {
normalised = t.replace(',', '.')
}
// Number() rather than parseFloat(): parseFloat stops at the first bad
// character, so "12.5abc" would quietly become 12.5.
const n = Number(normalised)
return Number.isFinite(n) ? n : null
}
// Same, but rejects zero and negatives — for fields that must be a positive
// amount, so callers can report which row is wrong.
export function parseAmount(input: string): number | null { export function parseAmount(input: string): number | null {
const n = parseFloat(input.replace(',', '.')) const n = parseDecimal(input)
return Number.isFinite(n) && n > 0 ? n : null return n !== null && n > 0 ? n : null
} }
+11 -4
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { ChevronLeft } from 'lucide-react' import { ChevronLeft } from 'lucide-react'
import { api, Account, Category } from '@/api/client' import { api, Account, Category } from '@/api/client'
import { parseDecimal } from '@/lib/utils'
export default function Settings() { export default function Settings() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -90,10 +91,16 @@ export default function Settings() {
} }
const saveAdjustBalance = async () => { const saveAdjustBalance = async () => {
if (!adjustingAccount || !adjustTargetBalance) return if (!adjustingAccount) return
// Zero and negative targets are legitimate, so parseDecimal, not parseAmount.
const target = parseDecimal(adjustTargetBalance)
if (target === null) {
setMutationError('Enter a target balance, e.g. 1234.56')
return
}
setMutationError(null) setMutationError(null)
try { try {
await api.adjustAccountBalance(adjustingAccount.id, parseFloat(adjustTargetBalance)) await api.adjustAccountBalance(adjustingAccount.id, target)
setAdjustingAccount(null) setAdjustingAccount(null)
setAdjustTargetBalance('') setAdjustTargetBalance('')
window.dispatchEvent(new CustomEvent('fin:transaction-added')) window.dispatchEvent(new CustomEvent('fin:transaction-added'))
@@ -263,8 +270,8 @@ export default function Settings() {
value={adjustTargetBalance} value={adjustTargetBalance}
onChange={e => setAdjustTargetBalance(e.target.value)} onChange={e => setAdjustTargetBalance(e.target.value)}
placeholder="Target balance" placeholder="Target balance"
type="number" type="text"
step="0.01" inputMode="decimal"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<button <button