Part 3 · 3 chapters · ~30 min
Deposits and Withdrawals
Idempotent submission with one key per intent minted at review and honoured through retries, timeouts and crashes; optimistic updates reconciled against the ledger with pending sub-states and explained corrections; and deposits per rail with virtual accounts, card flows that survive the 3-D Secure return, holds, failure modes with a path each, and the credit as an event with a receipt.
8
Idempotent submission
code
// the withdrawal mutation: one key per intent, optimistic with rollback, reconciled from the response, polled on timeout
export function useWithdraw() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ intent, key }: { intent: WithdrawIntent; key: string }) =>
api.post('/withdrawals', intent, { idempotencyKey: key, timeoutMs: 15_000 }),
onMutate: async ({ intent, key }) => {
await qc.cancelQueries({ queryKey: ['wallet'] })
const prev = { wallet: qc.getQueryData(['wallet']), txs: qc.getQueryData(['transactions']) }
qc.setQueryData(['wallet'], (w: Wallet) => ({ ...w, available: w.available - intent.amount - intent.fee, pendingOut: w.pendingOut + intent.amount })) // minor units, integers
qc.setQueryData(['transactions'], (t: Tx[]) => [{ id: 'local-' + key, key, kind: 'withdrawal', state: 'submitting', ...intent, at: Date.now() }, ...t])
return prev
},
onSuccess: (res, { key }) => {
qc.setQueryData(['transactions'], (t: Tx[]) => t.map(x => x.key === key ? res.transaction : x)) // replace the optimistic row by key
qc.setQueryData(['wallet'], res.wallet) // the balance from the server, never computed
clearDraft(key)
},
onError: async (err, { key }, prev) => {
if (err instanceof TimeoutError) { // the response may be lost: confirm, never fail
qc.setQueryData(['transactions'], (t: Tx[]) => t.map(x => x.key === key ? { ...x, state: 'confirming' } : x))
const final = await pollByKey(key, { every: 3000, upTo: 120_000 }) // GET /withdrawals?key=K until terminal or 'unknown'
if (final.kind === 'unknown') return retryWithSameKey(key) // never seen: safe to resend
return reconcile(final)
}
qc.setQueryData(['transactions'], (t: Tx[]) => t.map(x => x.key === key ? { ...x, state: 'failed', reason: reasonText(err) } : x)) // visible, with words
qc.setQueryData(['wallet'], prev!.wallet) // roll back the balance
},
onSettled: () => qc.invalidateQueries({ queryKey: ['wallet'] }), // reconcile with the ledger regardless
})
}
// the review step: const key = useDraftKey(intent) (minted when review renders, bound to the intent, persisted in sessionStorage, re-minted if the intent changes)one key per intent
- Minting: a UUID when the review step renders, held in state and sessionStorage keyed by the draft; bound to the exact payload (amount, destination, currency). Not at click (a double-click mints two); not at form open (editing after a failure must produce a new intent and a new key).
- Sending: the key in an
Idempotency-Keyheader; the button disabled on tap until a terminal response or the timeout handler; a network failure retries with the same key after a backoff, bounded, showing "still sending". - The server (the CBA module): first sight locks the key, processes, stores the result; a repeat with the same payload returns the stored result even days later; a different payload with the same key is refused (409); a repeat while processing waits or returns in-progress, which the client shows as pending.
- Timeout, the hard case: the server processed it and the response was lost. Never an error with a retry button that mints a new key. Retry with the same key (the stored success comes back), or move to "we are confirming" and poll by key until a terminal state. The user is never shown "failed" for something that may have succeeded.
- Return after a crash: the draft and key in sessionStorage; the client asks the server what happened to key K and renders pending, settled, failed, or unknown (never seen: safe to send with the same key). The key is the join between the client's memory and the ledger's truth.
- Lifetime: the server keeps keys at least 24 hours (longer for slow rails); the client discards the draft only on a terminal state or an explicit cancel. The sentence for every money screen's design doc: every money action has exactly one key, minted at review, sent on every attempt, and nothing the user does can send a second intent without seeing a second review.
IDEMPOTENT SUBMISSION
one key per intent, minted at review, reused on every retry, honoured by the server
swipe the figure sideways, or tap expand for full screen
1/6
minting
Minting: a UUID generated when the review step renders, held in component state and in sessionStorage keyed by the draft (so a reload of the review step keeps it). Not at click time: a double-click would mint two. Not at form-open time: editing the amount after a failed submit must produce a new intent and a new key (the key is bound to the exact payload: amount, destination, currency).
9
Pending, optimistic, reconciled
the client as a fast, honest preview of the ledger
- Optimistic: on submit, a pending row with the key at the top of the list and the available balance lowered by amount plus fee, with the previous state kept for rollback (the React course part 8's mutation lifecycle). The user sees the tap took effect.
- The response: success replaces the optimistic row by key with the server's transaction and takes the balance from the response (the server may apply a fee, a hold or a limit the client did not know); failure turns the row into a visible failed row with a reason in words and rolls the balance back, then refetches. A removed row is "it just vanished".
- Pending sub-states with copy and an estimate by rail: submitted, processing, in transit ("arrives within 5 minutes" versus "1 to 3 business days"), settled, or failed at any point with the money's location. The row shows the current state; the receipt shows the history with timestamps.
- Reconciliation on every refetch (focus, an interval while anything is pending, push): merge by server id and key. A pending row the server now calls settled updates in place; a row the server never received becomes "unconfirmed: tap to check"; a transaction the server has that the client does not (a deposit landed) is inserted with a "new" marker. Structural sharing keeps the rest stable (the FSD course M1 v3: one copy of each transaction).
- When they differ: the client expected 400 and the server says 380. The server wins instantly, and the UI says why if it can ("a ₦20 transfer fee was applied") or offers the receipt if it cannot. Silent corrections are how "the app stole ₦20" starts.
PENDING, OPTIMISTIC, AND RECONCILED
what the balance shows while money is in flight, and how the client catches up with the ledger
swipe the figure sideways, or tap expand for full screen
1/6
optimistic
The optimistic step: on submit (with the key), the client adds a pending transaction to its cache with a client-side id and the key, lowers the available balance by amount plus fee, and renders the pending row at the top of the list with its state. The user sees their action took effect. The query cache's mutation lifecycle (the React course part 8) does this: onMutate writes the optimistic state and keeps the previous state for rollback.
10
Deposits: money on someone else's schedule
per method, per rail
- Expectations at the choice: a card is instant or declined (and card-funded money is held for the chargeback window); a bank transfer to a virtual account credits when the sender's bank sends ("usually within minutes"); USSD, payment links and cash agents each have their clock. The copy is set when the method is chosen.
- The virtual account screen: the number copyable alone in one tap; the bank and the exact name to expect; the amount the user intends (for matching); "we will notify you" with the state on the home screen; an "I have sent it" that starts a short poll instead of leaving the user refreshing.
- The card flow: a tokenised field so the PAN never touches your page (PCI scope); the fee before; the 3-D Secure challenge where the processor demands it, with a return URL that restores the deposit screen in its pending state rather than the home page (where most card flows lose the user); decline reasons in plain words with "nothing was taken".
- Verification and holds: credited to the ledger yet held from available (card funds; a large transfer pending a source-of-funds check): "₦200,000 received. Available from Thursday after our standard check." Arrived and available are different facts; show both, named (part 4).
- Failure modes with a path each: a wrong reference (suspense matching by amount and sender, or "I sent it" with the bank's reference for a person to match); a partial amount (credited, shortfall shown); a duplicate (both credited, the user told); a decline after the challenge (the reason); a processor timeout ("confirming", never "failed" until the processor says so).
- The credit as an event: push or poll, the balance from the server, the row inserted with "new", a receipt complete enough to forward to the sender, and a notification for the user who closed the app.
the exercise
On your product's withdrawal screen, tap submit twice fast, then once with the network cut after the request leaves, then crash the app during submit and reopen. Three attempts, one withdrawal, and the screen told the truth throughout: that is the bar.
DEPOSITS: MONEY THAT ARRIVES ON ITS OWN SCHEDULE
cards, transfers and virtual accounts; expectations, verification, and the receipt that exists before the user asks
swipe the figure sideways, or tap expand for full screen
1/6
methods
Methods and expectations: a card (3-D Secure challenge in an iframe or a redirect; instant success or a decline with the issuer's reason; chargeback risk means a hold on withdrawals of card-funded money); a bank transfer to a virtual account (the user sends from their bank app; the credit arrives when their bank sends: "usually within minutes"); a payment link or USSD; a cash agent. Each sets its own copy at the moment the user chooses it.