The Ledger: The One Thing You Must Get Right on Day One

The Ledger: The One Thing You Must Get Right on Day One

Quick answer: Payment ledger design is the one part of a fintech product you cannot cheaply rewrite later. Record money as immutable, append-only double-entry records — never as a balance column you update. Give every money-moving operation an idempotency key. Model the payment lifecycle as an explicit state machine with terminal states, and treat "unknown" as a real state you resolve by reconciliation rather than assumption. Build reconciliation against provider statements from day one, and store amounts as integers in minor units, never as floats.

A payment ledger as an append-only chain of balanced entries rather than a single balance column
A payment ledger as an append-only chain of balanced entries rather than a single balance column.

Almost everything in a product can be rewritten — the front end, the payment provider, the onboarding flow. The way you record money is different. Get it wrong and the mistake does not announce itself: it surfaces a year later as a few hundred euro of discrepancy nobody can explain, in a system where nobody can reconstruct what happened.

Our founder spent two and a half years on Wise's core payment platform team — the team that owned payment creation and the entire payment lifecycle. Since then we have built cross-border crypto-to-fiat transfer products and a market surveillance platform for regulated markets. This is the short version.

Why can't you fix the ledger later?

Because the ledger is not a feature, it is your record of the past. Everything else describes what the system does now; the ledger claims what happened. So a bad ledger is not repaired by rewriting code. It is repaired by a forensic project against production data, under deadline, while an auditor waits.

What does "a balance is not a column" mean?

The instinct is a users table with a balance column, updated on every transaction. That model has no history, no explanation and no way to prove itself. If two requests race, one silently wins.

Double-entry says something stronger: money never changes, it only moves. Every movement is recorded twice — where it came from and where it went — and the two sides sum to zero. A balance is then not a stored number at all. It is the sum of all entries against an account, derived on demand (cache it if you must; the entries remain the truth). That buys you three things:

  • A self-check. Every entry in a given currency must sum to exactly zero across the whole system. If it doesn't, you have a bug — and you learn it in minutes, not months.
  • An explanation. "Why is this balance €412.30?" is answered by listing the entries that produced it.
  • A place for everything. Fees, in-flight payouts, provider float, FX positions, promotional credits — each gets an account. Money "somewhere in between" has a name instead of being invisible.

What entries does a €100 EUR → PLN transfer create?

A customer sends €100 from their EUR balance, you charge a €1 fee, and the recipient is paid in złoty at 4.30, so €99 becomes 425.70 PLN. A single journal cannot balance across two currencies — you cannot add euro to złoty — so the transfer decomposes into self-balancing legs, joined by an internal FX position account.

# When Account Cur Debit Credit
1 Accepted Customer EUR wallet (our liability) EUR 100.00
2 Accepted Fee income EUR 1.00
3 Accepted FX position — EUR EUR 99.00
4 Accepted FX position — PLN PLN 425.70
5 Accepted Payout in flight PLN 425.70
6 Settled Payout in flight PLN 425.70
7 Settled PLN bank PLN 425.70

Rows 1–3 sum to zero in EUR; 4–5 and 6–7 each sum to zero in PLN. Nothing is overwritten — settlement adds entries.

Note what row 5 buys you. Between acceptance and settlement the money is neither with the sender nor the recipient; it sits in a named account, so "where is this money right now?" has an answer.

The double-entry records created by a €100 EUR-to-PLN transfer, showing the EUR leg, the PLN leg and the internal FX position account that joins them
The double-entry records created by a €100 EUR-to-PLN transfer, showing the EUR leg, the PLN leg and the internal FX position account that joins them.

Why must financial records be immutable?

The rule is blunt: no UPDATE and no DELETE on ledger records. A correction is a reversing pair of entries plus the corrected pair, both referencing the original. The wrong entry stays visible forever, marked as reversed. The same rule binds support tooling — it must move money through the normal code path, never by touching the database, because a second privileged way to change balances means you no longer have a record.

Two things depend on this. Audit: a regulator or partner bank will ask what an account's balance was on a given date. Append-only makes that a query with a timestamp filter; mutable rows make it archaeology against backups. Debugging: the hardest payment incidents are quiet divergences, not crashes. With immutable records you replay the sequence and find the entry that shouldn't be there; with editable rows the evidence was destroyed, often by a well-meant support fix.

What is an idempotency key, and where does it live?

Networks fail in the worst possible way: the request arrives, the work is done, the response is lost. The client cannot distinguish that from "nothing happened", so it retries — and you have sent the money twice.

An idempotency key fixes this. The client generates a unique key (a UUID is fine) before the first attempt and resends the same key on every retry of that operation. The server, in the same database transaction that creates the payment, writes the key to a table with a unique constraint. If the key already exists, no new payment is created and the stored original response is returned.

  • Store the key in your own database, in the same transaction as the effect, with the unique constraint doing the enforcement. A key held in a cache, or written after the payment, fails exactly when you need it — and a unique index is also what stops two simultaneous requests with the same key from both proceeding.
  • Bind the key to the request by storing a hash of the payload. The same key with a different body is a client bug, to be rejected rather than silently accepted.
  • Keep keys long enough. Retries arrive hours or days later; aggressive expiry is how duplicates come back.
  • Use keys downstream too. Derive the key you send the provider from your own payment identifier — deterministically, not per attempt — so your retries don't create two payouts at their end.

What states can a payment be in — and what is the hardest one?

Model the lifecycle explicitly: named states, an enumerated set of allowed transitions, terminal states nothing exits — something like created → funds reserved → submitted → settled, with cancelled, failed and returned as terminal outcomes. Each transition is the only place that emits ledger entries, so state and ledger cannot drift apart.

Terminal means terminal. A settled payment that later comes back is not "unsettled" — it is a settled payment plus a return, with its own entries. Reversing a state destroys history.

Then the hardest state in payments, and the one most designs omit: unknown. You call the provider and the connection times out. You do not know whether they executed. This is not a failure — treating a timeout as failure is precisely how money goes out twice — so it needs its own state and its own handling:

  • Persist "submitting", with the deterministic provider idempotency key, before the outbound call.
  • On timeout or an ambiguous error, move to pending-confirmation, never to failed.
  • Resolve by asking: poll the provider's status endpoint; if inconclusive, wait for the statement. Never by assumption.
  • Keep the money in the in-flight account meanwhile — it must not return to the customer's spendable balance until the outcome is known, or they can spend it while you are still debited.
  • Give it a timeout budget and a human escalation path.
A payment lifecycle state machine with terminal states and an explicit unknown state resolved by polling and reconciliation
A payment lifecycle state machine with terminal states and an explicit unknown state resolved by polling and reconciliation.

Why is reconciliation a product feature, not a report?

The most common mistake after the balance column is treating the provider's callback as truth. A webhook can be lost, duplicated, delivered out of order, or arrive before your own transaction commits. Any design where a callback is the sole trigger for a state change will eventually be wrong.

  • Outbox. You cannot atomically commit a database change and make an external API call, so don't try. Write the intent to send as a row in the same transaction as the state change; a worker reads it and makes the call, retrying safely because the call carries an idempotency key.
  • Inbox. Store every inbound webhook raw, keyed by the provider's event id, and acknowledge immediately; process separately from that store. Duplicates are dropped by the event id, out-of-order events by the state machine refusing backwards transitions.

Above both sits reconciliation against the provider's statement: every day, match their record of what moved against your ledger into four buckets — matched, in your ledger only, in theirs only, and matched-but-different-amount. That break report should be a screen someone reads every morning from the first week of production, not a spreadsheet built in year two because a bank asked for it.

What breaks when you add a second currency?

Naive models survive one currency and fall apart at two.

Naive model What it should be
amount as a float or double Integer minor units, or an exact decimal type
One amount column, currency implied Amount and currency code stored together, inseparably
Two currencies in one balanced journal One self-balancing leg per currency, joined by an FX position account
Only the converted amount stored Source amount, target amount, rate, rate source, rate timestamp
Rate looked up whenever needed Rate fixed at a defined moment, quoted with an expiry
FX difference absorbed silently An explicit FX P&L account that carries it

Floats deserve their own sentence. Binary floating point cannot represent 0.1 exactly, so 0.1 + 0.2 is not 0.3, and in a ledger those fractions accumulate until your zero-sum check fails by a hundredth of a cent. Storing money as a float is not a style preference, it is a defect. Note too that minor units are not universally two decimals — some currencies have none, some three, crypto far more.

The other question multi-currency forces is who carries the FX difference. If you quote a rate and buy the currency later at a different one, someone absorbs the gap. Decide deliberately and give it an account, rather than meeting it later as drift.

How can you tell your ledger is already wrong?

Checks a founder can run without reading code — ask your engineers:

  • Where is the balance stored? A column that gets updated means you do not have a ledger.
  • What was account X's balance three months ago? If it needs a backup restore, history isn't kept.
  • How does support fix a wrong balance? If the answer involves SQL, there is a second way to move money.
  • Where is the money during an in-flight transfer? If no account holds it, in-flight money is invisible.
  • What if the app sends the same transfer twice? No idempotency key means duplicates are a matter of time.
  • What happens when the provider call times out? If the payment is marked failed, you will eventually pay twice.
  • What checks that all entries sum to zero, and how often? If nothing checks, nothing is guaranteed.

No single answer is fatal. A pattern of them means a rewrite is coming.

FAQ

Do I really need double-entry for an MVP?
Yes, and it is cheaper than the alternative. The core is small: an accounts table, an entries table, a rule that entries are written in balanced groups, and a check that they sum to zero. Retrofitting one onto a year of production data is not.

Should I build my own ledger or use a ledger product?
Both are defensible — ledger services and open-source ledgers encode good defaults. What is not defensible is the third option: a bespoke balances table with no double-entry underneath. If you buy, verify immutability, multi-currency and idempotency are native.

Where should idempotency keys be stored?
In your primary transactional database, in the same transaction as the effect they protect, with a unique constraint doing the enforcement — not in a cache, and not via a read-then-write check two concurrent requests can both pass.

What is the difference between a payment status and a ledger entry?
Status is the current position in a state machine; entries are the immutable record of money moved. Let only transitions create entries, so the two can be checked against each other.

Do these decisions affect licensing or regulatory reporting?
They affect how easily you can satisfy it, but the requirements themselves — safeguarding of client funds, accounting treatment, chart of accounts, reporting obligations — must be confirmed with a qualified accountant and a regulatory lawyer for your jurisdiction and licence.

Build it once, properly

A ledger is a small piece of code with an unusually long shadow. Design it before the first real payment, and design it with people who have watched one fail.

If you are weighing whether to build that capability internally, we have written about the trade-offs between a dedicated development team and hiring in-house. To compare vendors properly, our RFP template for software and hardware projects covers the sections most fintech briefs leave out.

Tell us what you are building and we will review your ledger design — or draft one with you. Request a quote → or book a call with our engineers →.

GPO-Tech designs and builds connected products and commercial software end to end — from one team in Tallinn, Estonia.

Request a quote →