BuildFinding your footing

Your Startup Doesn't Need an Accountant Yet — It Needs a Ledger

Append-only postings survive payouts — mutable balance columns don't.

6 min read · June 17, 2026

#Fintech #SoftwareEngineering #PostgreSQL #Payments #Startups

Accountants learned immutability centuries ago. Your balance column didn't.
Accountants learned immutability centuries ago. Your balance column didn't.

Most engineers know how to add a wallet. Few know how to add a ledger.

The difference shows up the first time finance asks why March's user balances don't match the bank statement — and your only artifact is users.balance with no history of who moved what.

You can ship credits, referrals, and marketplace payouts without hiring an accountant. You cannot ship them with a mutable balance column and hope reconciliation appears later. That hope is where startups lose weekends — and sometimes real money.

The failure mode is specific: two parallel API requests read balance = 100, both subtract 60, both write 40. You did not lose $20 in logic. You lost the audit trail that would have caught it. Double-spend is a concurrency bug wearing a finance costume.

That's the cliff.

This piece is for backend engineers building the money layer — not for GAAP compliance, not for tax reporting. One abstract walkthrough: transferring 500 platform credits between two users while a payout saga talks to an external rail. Enough to choose a schema on Monday.

The Balance Column Trap — mutable columns aren't ledgers

The naive model is seductive. users.balance DECIMAL(10,2). Credit on signup. Debit on purchase. Refund adds back. Done.

Except refunds become investigations. A support agent asks: "Show me every movement for user 4821 in February." You have one number and a guess. Stripe's internal Ledger treats money as an immutable event log — published transactions are not deleted or modified; you reconstruct state by replay. Modern Treasury enforces the same contract at the API layer: once a transaction posts, its entries don't change.

Corrections are new rows, not UPDATE. Customer was overcharged? Post a reversing entry. Payout failed after you debited? Post a compensation entry. The history stays complete. Auditors — and your future self — care about the sequence, not the latest cell value.

Money as integers, not floats

Store $12.34 as 1234 cents in an int64, with an ISO currency code beside it. Floats are binary approximations; money needs exact base-10 arithmetic. 0.1 + 0.2 is a famous bug outside fintech and a production incident inside it — Modern Treasury documents why they store int64 minor units only.

JavaScript makes this worse: number is a float. Do wallet math on the server in integers. Convert to major units only at the API boundary for display.

Double-entry in one sentence

Every movement has a source and a destination. Debit one account, credit another; per currency, the amounts sum to zero. You are not "subtracting balance" — you are posting a transfer between named accounts. That invariant is how you prove you neither created nor destroyed funds.

How to store it — accounts, postings, and ledgers

Think in three nouns:

  • Account — a bucket (user wallet, platform fees, payouts clearing). Belongs to exactly one currency ledger.
  • Posting (entry) — one line: account, direction (debit/credit), amount in minor units, transaction id.
  • Transaction — an atomic bundle of postings that balance. Metadata: idempotency key, external reference, FX rate if applicable.

The user's "balance" is SUM(postings) for their account — a read model you can cache, not the source of truth. If the cache drifts, replay the log.

One currency = one ledger

USD wallets and EUR wallets do not share one summed float. Each currency is a separate ledger with its own balanced books. Moving $10 to €9 is two transfers: debit USD account, credit USD FX clearing; debit EUR clearing, credit EUR account — with the rate recorded as metadata, not blended into a magic number.

Client balance is derived, not stored

You may materialize wallet_balance for fast reads. Treat it like a cache with an invalidation story: rebuild from postings nightly, or update synchronously inside the same DB transaction as the postings insert. Never let product code write the balance without writing the postings that justify it.

Parallel transfers — idempotency beats optimism

Two requests hit at once. Without discipline, both pass a balance >= amount check and you ship a double spend.

Your options, stacked:

  • Idempotency keys on every money API. Client sends Idempotency-Key: transfer-9f3a…; server stores the result. Retries after timeout return the same outcome instead of double-charging.
  • Serializable write on the account row (or ledger-native locking) when creating postings that depend on current balance.
  • Append-only postings so even if you screw up, you can reconcile — sum entries and find the gap.

The invariant to test: same idempotency key + same payload = same transaction id, every time.

Sagas and compensations — when payout crosses the database boundary

Real flows don't fit one BEGIN … COMMIT. User cashes out credits → you reserve in the ledger → call payout provider → mark settled → send email. Step three fails. Now what?

A saga runs local transactions in sequence; if step N fails, compensating transactions undo steps N-1 … 1 — not with ROLLBACK across services, but with forward operations: release hold, post refund, cancel payout. Each compensation is idempotent and retried until it sticks.

Abstract walkthrough: 500-credit transfer with payout

Happy path (peer transfer):

  • API receives POST /transfer with idempotency key k-771.
  • Ledger service writes transaction T1: debit sender wallet 500, credit receiver wallet 500 (USD ledger). Atomic — all entries or none.
  • Done. Two users, one balanced transaction, immutable log.

Payout saga (sender withdraws to bank):

  • Reserve — T2 debits user wallet 500, credits payouts_pending 500.
  • External — Payout API sends $5.00 to bank. Outside your DB.
  • Settle — T3 debits payouts_pending 500, credits payouts_settled 500.
  • Notify — Email worker fires.

Failure at external step (provider timeout):

  • Compensation: T2′ credits user wallet 500, debits payouts_pending 500 — a reversal posting, not a delete of T2.
  • Saga state: compensated. User sees balance restored. Support sees both T2 and T2′ in the log.

Pivot transaction (Azure's vocabulary): use authorize/hold before capture when the external step is irreversible. Reserve is compensable; captured wire is not — design the boundary deliberately.

Orchestration beats choreography when money is involved. One state machine you can inspect beats five services guessing who goes next.

What to build on day one vs what to buy

Day one minimum:

  • ledger_accounts, ledger_transactions, ledger_entries tables (append-only entries after post).
  • Integer minor units + currency on every amount.
  • Double-entry validation in application code before insert.
  • Idempotency table keyed by (tenant, idempotency_key).
  • Saga state table if you touch external payment rails.

Buy or adopt when volume demands it: Modern Treasury Ledgers, TigerBeetle, or patterns like Stripe's internal ledger as a north star — not because Postgres can't ledger, but because contention math gets hard at thousands of writes per second per hot account.

You don't need a finance team to get the schema right. You need to stop treating money like profile fields.

Postings are the product.

Balance is the headline.

More in Build

← Back to hub