A Ledger in Production: Providers, Exactly-Once, and the Close
A double-entry ledger fits on a whiteboard. Every journal entry's debits equal its credits, balances are summed from postings rather than stored, and a hold reserves a customer's money without moving it, so it lives in its own table rather than the journal. Production adds the parts that don't fit on the whiteboard. You have a bank, a card processor and an FX provider, each keeping its own records. Events arrive twice, out of order, or not at all. And finance needs the same answer for last March every time they ask.
This is how I handle each of those.
Providers are accounts, not separate ledgers
One ledger. The bank, the card processor and the FX provider each get accounts in the same chart of accounts as customer money. Customer money itself sits in safeguarded cash, a segregated account at the bank that holds what you owe customers, separate from your own operating cash. Not all of them are assets. Between clearing and settlement you owe the card scheme money, and the card flow below shows exactly that balance. A flow that crosses two providers then becomes one balanced set of postings, instead of two half-transactions you join by hand at month end.
Alongside the ledger, keep a mirror of each provider's records: statement lines, authorisations, fee bills. The mirror has no authority. When it disagrees with the ledger, the result is a discrepancy to investigate, never an automatic correction. The moment a mirror can write to your ledger, a third party is authoring your record.
A customer spends 100 on a card. The processor authorises it, and the money leaves the bank days later:
| Step | Debit | Credit | Amount |
|---|---|---|---|
| Authorisation approved | no posting, a hold reserves it | 100.00 | |
| Clearing arrives | customer:123:wallet:GBP | clearing:card:GBP | 100.00 |
| Settlement leaves the bank | clearing:card:GBP | cash:bank:safeguarding:GBP | 100.00 |
| Scheme fee debited | expense:scheme_fees:GBP | cash:bank:operating:GBP | 0.30 |
Every step balances. Between clearing and settlement, the customer's wallet has already dropped by 100 while the cash is still at the bank, so safeguarded cash sits 100 above what you owe customers. Without the clearing account that looks like an unexplained surplus. With it, the ledger says where the money is and who it's owed to, and the clearing account returns to zero once settlement lands.
Now a conversion, which is where the postings get harder to follow. A customer converts £1,000 to euros. The FX provider's rate is 1.1700 and the customer gets 1.1500, so they receive €1,150.00. Buying €1,150.00 at 1.1700 costs £982.91, and the £17.09 difference is your margin:
| Step | Debit | Credit | Amount |
|---|---|---|---|
| Trade confirmed | customer:123:wallet:GBP | clearing:fx:GBP | £982.91 |
customer:123:wallet:GBP | revenue:fx_margin:GBP | £17.09 | |
clearing:fx:EUR | customer:123:wallet:EUR | €1,150.00 | |
| GBP leg settles | clearing:fx:GBP | cash:bank:safeguarding:GBP | £982.91 |
| EUR leg arrives | cash:fx_provider:safeguarding:EUR | clearing:fx:EUR | €1,150.00 |
The trade is one journal entry, because it's one economic event, and it touches two currencies. The balance check groups postings by currency, so pounds balance against pounds and euros against euros, and nothing ever compares one with the other. Keeping it as one entry also means one leg can't post without the other.
The euros land at the FX provider, not the bank, which is the provider dimension on the account doing its job. The margin is earned at confirmation but still sitting in safeguarded cash, so it waits for the next fee sweep to move to operating cash. And £982.906 rounds up to £982.91, so the fraction of a penny comes out of your margin rather than the customer's euros. That's a rounding rule you chose, written down, rather than one that happened to you.
Name every account the same way
Every account carries a class, a legal entity and a currency, plus a provider and a wallet where they apply: cash:bank:safeguarding:GBP, customer:{id}:wallet:GBP, clearing:card:GBP. Clearing accounts get one per route, because a route is the thing that gets stuck.
The classes I start with: customer liability, unattributed receipts, safeguarded cash, operating cash, customer receivable, fee revenue, FX margin, provider charges, and clearing. Two of them do more work than they look like.
Unattributed receipts keeps you honest. Money that arrives without enough information to name a customer is a liability of the business, not a credit to whichever wallet looks closest. Guess, and you get a wrong ledger plus a complaint from the person whose money you gave to someone else. It also counts towards the total you owe, so it needs a real account, not a spreadsheet tab.
Clearing accounts must clear. Watch the individual items, not the net balance, because a busy clearing account may never sit at zero and two stale items in opposite directions cancel each other out. The oldest unsettled item is usually your earliest warning that something is stuck, ahead of the provider telling you and well ahead of a customer. Alarm on its age.
One migration warning: put the legal entity on every account from day one, even with one entity and no plans for a second. Retrofitting it across a live ledger with years of history is a migration that touches every row you have.
Exactly once, and how you actually get it
Every entry point takes an effect key and is idempotent on it. A unique constraint on that key turns "applied once" from a hope about your retry logic into a property of the database.
Keys come from the source, never from a UUID generator: the provider's transaction ID for money in and out, the instruction ID plus a suffix for its fee, the original entry's ID for a reversal.
A replayed event finds its key already present and returns the existing journal entry. That's the whole mechanism.
Getting events out of your own service reliably is a solved problem: the transactional outbox. Write the event in the same database transaction as the postings, and have a worker publish it. I use a polling publisher: claim rows under a lock with a lease and a due time, back off on failure, and mark a row dead after its retry budget so it shows up in the back office instead of disappearing. The outbox gives you at-least-once delivery, not exactly-once. The receiving side keeps a processed-messages table keyed on subscriber and message ID, so a duplicate insert fails before a duplicate effect lands.
At-least-once delivery, an idempotent consumer and a unique effect key: that's how you get exactly-once effects out of infrastructure that can only promise you duplicates.
The one gap: a key only catches a duplicate that carries the same key. If the same bank transaction reaches you by webhook and again in a statement file, with the reference formatted differently each time, you have two keys and two postings. Pick one canonical identifier per provider and normalise to it at ingest.
Nothing writes a posting directly
Every write goes through a short list of named entry points, called by only three kinds of caller: a customer decision, a provider event, or an operator correction.
| Entry point | What it does |
|---|---|
| Approve a payment | Posts the fee and places the hold, in one transaction |
| Apply a provider event | Posts the movement the provider says happened |
| Resolve an unknown outcome | Releases the hold and reverses the fee once the payment is known to have failed |
| Sweep fees | Moves earned fees and margin from safeguarded to operating cash |
| Post a correction | Writes a reversing or correcting entry, second approver required |
| Close the day | Writes general ledger rows, never postings |
The list is short on purpose. When someone asks how money could possibly have moved, there are five answers, and each one leaves a trail. The sixth entry point, the close, can't move money at all.
The general ledger is derived, never typed
A daily close sums the day's postings per account, entity and currency into an immutable row with an opening and closing balance. Your transactional ledger is a sub-ledger to the company's general ledger, and the customer funds control account there must equal the sum of your customer liability accounts. Not approximately. Exactly.
Deriving the close means it can't drift from the ledger because someone mistyped. It can still drift through a mapping bug, a missed account or an inconsistent cut-off, so reconcile the control accounts after every close. And know where your ledger stops: this is the payments sub-ledger feeding the general ledger, and finance has legitimate journals of its own.
The two ledgers serve different readers. The transactional ledger is operational truth and has to answer a card authorisation in milliseconds. The general ledger is reporting truth, and what matters there is that a closed period gives the same answer every time you ask.
When things go wrong
| What happened | What the ledger does |
|---|---|
| Provider rejects the payment before anything moves | Release the hold, reverse the fee. Net zero, both entries visible |
| Provider times out, outcome unknown | Nothing posts. The hold stays until a resolver learns the real outcome |
| Submission fails after the debit already landed | The bank credits the money back. Both transactions post, and the wallet is whole again |
| Receiving bank returns it days later | A new money-in entry linked to the original. The original debit stands |
| Two provider payments exist for one instruction | Both post, because both are real money. One gets reclaimed |
| Provider's balance disagrees with yours | No posting. A discrepancy, and a correction only with a second approver |
Two rows are worth remembering. A failed payment can produce two transactions at the bank, not zero, so a handler that expects "failed" to mean "nothing happened" will miss the first one. And a return is not a reversal. The money left and came back, so the ledger records two movements rather than editing one. That isn't pedantry: the bank statement shows two transactions, and a ledger holding one edited movement will never reconcile against it.
What I'd refuse, and why
| Option | Why not |
|---|---|
| One ledger per provider | No single query can say what a customer holds |
| Treat the provider's balance as the record | Holds, fees and money in flight have nowhere to live |
| Post card authorisations as journal entries | Most of your writes become reversals |
| General ledger entered by hand | It can disagree with the money, and then you have two records |
| Buy a ledger service | Worth pricing. The blocker is compliance sign-off on your record living on someone else's systems, not the architecture |
How to know it works
Ledger bugs are unusually expensive. Luckily, ledgers are unusually testable.
- Property tests that generate random journal entries and assert the trial balance is zero per entity and per currency.
- A conversion test asserting each currency balances on its own and the margin equals the rate difference, to the penny you chose to round.
- Card tests where clearing arrives for more than the authorisation, for less, and not at all, asserting the hold is settled or released exactly once each time.
- A close test that rebuilds a closed day from its postings and compares byte for byte.
- A test that applies the same provider event twice and asserts one posting.
Then one test that's easy to skip. Hand a trial balance and a general ledger export to an accountant and ask whether they can use it. Not whether it's correct - whether they can use it. If they can't, the daily close gets redone in a spreadsheet, and that spreadsheet becomes the number everyone quotes.
Trace one posting
Pick one posting from last month and walk it back: the entry point that wrote it, the provider event behind it, and the key that stopped it posting twice. If any link in that chain is missing, that's where your next incident starts.