Part 4 · 3 chapters · ~25 min
Wallets and Balances
The five balances and the one the user may spend, where each is shown and how it is labelled, limits that bind the same input, money as integers in minor units with a declared exponent and a named rounding, formatting last through Intl and locale-aware input, and real-time balances applied by version with an age on screen.
11
Which balance
five numbers, one spendable
- Ledger (settled entries summed), holds (reservations: a withdrawal in flight, a card authorisation, a deposit under verification), available (ledger minus holds: the only number the user may act on), pending in (credited, not yet available), pending out (leaving, not yet settled, may still return). The CBA module keeps all five; the client shows and labels, never derives.
- Where: the home screen shows available, labelled, large, with pending in and out as small lines when non-zero ("₦200,000 arriving · ₦100,000 on its way"); the ledger balance only in statements and detail views, labelled. A large unlabelled ledger balance while a withdrawal is in flight teaches the user the app lies.
- Authorising: the limit inline as the user types ("you can send up to ₦380,000.00 now") with the gap from the ledger explained ("₦120,000 is on hold: a transfer in progress"); the server's atomic hold is the authority and refuses with a reason; the client should have prevented it, kindly.
- Movement, watched: a withdrawal of 100 from 500: available 400 at submit (a hold), pending out 100; on settlement ledger 400, hold released. A deposit of 200 verifying: pending in 200, available unchanged; on release, available 600. Each step a labelled rendering.
- Limits (daily, per-transaction, by tier) bind the same input; show the one that binds: "up to ₦380,000 now (₦1,000,000 daily; ₦620,000 used today)".
- Anti-patterns: one unlabelled number; available computed client-side (the server knows holds the client does not); the ledger on the home screen; pending in hidden so a deposit "disappears"; rounding for display before the arithmetic is done.
WHICH BALANCE
ledger, available, pending in, pending out, holds: five numbers, and the one the user may spend
swipe the figure sideways, or tap expand for full screen
1/6
the five
The five: ledger (settled entries summed: what the books say); holds (reservations the ledger has placed: a withdrawal in flight, a card auth not yet captured, a deposit under verification); available (ledger minus holds: spendable now); pending in (credited to a pending bucket, releases on verification or settlement); pending out (debited from available, not yet settled, may still fail and return).
12
Money is integers with an exponent
code
// money.ts: integers in minor units, a currency with an exponent, named rounding, formatting last
export type Currency = 'NGN' | 'USD' | 'EUR' | 'JPY' | 'BTC'
const EXP: Record<Currency, number> = { NGN: 2, USD: 2, EUR: 2, JPY: 0, BTC: 8 }
export type Money = { readonly amount: bigint; readonly currency: Currency } // never a float; never a string with a point
export const add = (a: Money, b: Money): Money => { assertSame(a, b); return { amount: a.amount + b.amount, currency: a.currency } }
export const sub = (a: Money, b: Money): Money => { assertSame(a, b); return { amount: a.amount - b.amount, currency: a.currency } }
export type Rounding = 'HALF_EVEN' | 'HALF_UP' | 'DOWN'
function divRound(n: bigint, d: bigint, mode: Rounding): bigint { // one rounding, at a named point, with a named mode
const q = n / d, r = n % d, twice = r * 2n
if (mode === 'DOWN' || r === 0n) return q
if (mode === 'HALF_UP') return twice >= d ? q + 1n : q
return twice > d ? q + 1n : twice < d ? q : (q % 2n === 0n ? q : q + 1n) // HALF_EVEN
}
// a rate as a scaled integer: 0.21% = { num: 21n, den: 10000n }; an FX quote ₦1,580.4321/$ = { num: 15804321n, den: 10000n }
export const applyRate = (m: Money, rate: { num: bigint; den: bigint }, mode: Rounding, currency = m.currency): Money =>
({ amount: divRound(m.amount * rate.num, rate.den, mode), currency })
export const fee = (gross: Money) => applyRate(gross, { num: 21n, den: 10000n }, 'HALF_UP') // the rule names the mode; the receipt shows the result
// formatting, last; digits from the integer, locale from Intl; never parsed back
export function format(m: Money, locale = 'en-NG'): string {
const exp = EXP[m.currency]; const neg = m.amount < 0n; const abs = neg ? -m.amount : m.amount
const s = abs.toString().padStart(exp + 1, '0'); const major = s.slice(0, s.length - exp), minor = s.slice(s.length - exp)
const str = exp ? `${major}.${minor}` : major // a decimal string, not a float
return new Intl.NumberFormat(locale, { style: 'currency', currency: m.currency, minimumFractionDigits: exp, maximumFractionDigits: exp }).format(neg ? `-${str}` as any : str as any)
}
// input: parse(locale, "250.000,00") → strip group separators by locale, split on the locale's decimal, accept ≤ exp decimals → 25000000nthe rules
- The float trap: 0.1 + 0.2, 1.15 × 100, a sum of a thousand floats, parseFloat on a formatted string: each is wrong by a cent somewhere visible (the JS course part 6 on the representation). A balance off by a cent is a screenshot in a complaint.
- Representation: an integer count of minor units with a currency that declares its exponent (NGN 2, JPY 0, BHD 3, BTC 8), as a BigInt (or a number under 2^53 with discipline). Never a float, never a decimal string, in the model or on the wire.
- Arithmetic: integers for add and subtract; rates and percentages as scaled integers (0.21% is 21/10000) applied as multiply then one divide-and-round with a named mode (HALF_EVEN for ledgers, HALF_UP for consumer display, DOWN for what the user receives when in doubt) at the point the business rule names. Two roundings in different places is a reconciliation bug; the receipt shows the result of the rule.
- Cross-currency: the quote carries its own scale and rounding; "₦1,580.43 per $1; you receive ₦789,215.00" matches the receipt to the kobo; never chain through a float or recompute from the displayed rate.
- Formatting, last: digits from the integer (major and minor as strings), locale from
Intl.NumberFormat(separators, symbol position), decimals from the exponent; never parsed back; "₦250K" only in charts. - Input: a locale-aware parser (separators stripped, the locale's decimal, at most exponent decimals) producing the integer; formatted on blur, never under the cursor; the review shows it formatted; the submit sends the integer. One component, tested with every locale the product serves.
MONEY IS INTEGERS WITH AN EXPONENT
minor units, currency exponents, rounding that is named, and formatting that happens last
swipe the figure sideways, or tap expand for full screen
1/6
the float trap
The float trap: 0.1 + 0.2 !== 0.3; 1.15 * 100 is 114.99999999999999; a sum of a thousand transactions drifts; parseFloat on a formatted string loses the thousands separator. The JS course part 6 explains the representation; the consequence here is a balance that disagrees with the ledger by a cent, which a user will screenshot.
13
Real-time balances that do not lie
versions, not arrival order
- The event (the FSD course M8) is a full balance snapshot with a version (the ledger's sequence for this wallet) and a cause: idempotent and orderable where a delta is neither; the cause lets the UI say why the number changed.
- Applying: replace only if the event's version is higher than what is held; a fetch response carries a version and obeys the same rule. A fetch that started before an event and returns after it is ignored by version; without versions the balance goes backwards on screen.
- Gaps: a missed version loses nothing for the balance (the next snapshot is complete) but leaves a hole in the activity feed: replay from the last seen version (M8) or refetch the list.
- Tabs converge because versions are global; a hidden, throttled tab refetches once on visibility rather than replaying an hour; the leader-tab pattern (M7) shares one socket if the count matters.
- Age on screen: "current" while connected with a recent heartbeat; "as of 14:02" when disconnected and not yet refetched; "updating…" during the refetch. A user deciding on a balance deserves its age (M9's lag readout, for a wallet).
- What the event must not do: flash on every change (a slot machine); reorder the list under a reader (M6: anchored inserts or a "new" marker); change an amount in a form being filled (the limit updates; the typed value does not).
the exercise
Find every number on your wallet screen and write its name next to it (ledger, available, pending in, pending out, a limit). Then find where each is computed. Any that the client computes, and any that is a float on the way, is the next bug.
REAL-TIME BALANCES THAT DO NOT LIE
events, versions, and a number on screen that is never older than it claims
swipe the figure sideways, or tap expand for full screen
1/6
the event
The event: { walletId, version: 9042, available: 38000000, ledger: 50000000, holds: 12000000, pendingIn: 20000000, pendingOut: 10000000, cause: { kind: "deposit", ref } }. The whole balance set, not a delta: a delta applied twice or out of order corrupts; a snapshot with a version is idempotent and orderable. The cause lets the UI say why the number changed.