Skip to main content

Command Palette

Search for a command to run...

Multi-Currency and FX in a Go Ledger: Keeping Double-Entry Honest Across Currencies

You send 100 dollars, your friend abroad receives 92 euros, and a few cents seem to vanish into thin air. This week I built the currency exchange machinery that makes those cents go exactly where they should, and never anywhere else.

Updated
13 min readView as Markdown
Multi-Currency and FX in a Go Ledger: Keeping Double-Entry Honest Across Currencies

Here is a thing that has quietly bugged me my whole adult life. I send someone in another country 100 US dollars. My app says they will receive, let us say, 92 euros. But if I do the math on today's exchange rate, it should have been 92 euros and 34 cents. Where did the 34 cents go? And a harder question: when a real bank does this a million times a day, where do all those little slivers of a cent add up to, and who is keeping track so that not a single one is created or destroyed by accident?

This week I built that machine. go-ledger can now hold accounts in different currencies and move money between them, converting as it goes, without ever breaking the one rule this whole project is built on: money is never created and never destroyed, it only moves. This is post 11 of 14.

I want to write this one for people who have never touched foreign exchange or core banking, because it turns out the ideas are genuinely beautiful once someone lays them out slowly, and almost nobody does. If you already know what a nostro account is, skim. If you do not, you are exactly who I am writing for.

First, a one-paragraph refresher on what a ledger even is

A ledger is a list of movements of money. The core rule is double-entry: every transaction is made of two or more entries, called postings, and those postings must add up to exactly zero. If I pay my friend 600 taka, that is not one event, it is two: minus 600 from my account, plus 600 to theirs. They sum to zero, so no money appeared or vanished, it just moved. Your balance is not a number stored somewhere that someone edits. It is the sum of every posting that ever touched your account. The history is the truth, and the balance is just a question you ask of that history. Hold onto "postings must sum to zero," because the entire drama of this week is about that one sentence surviving contact with two different currencies at once.

What is a currency, to a ledger?

Before this week, every account in go-ledger had a currency label, but the whole system was really single-currency: a transaction picked one currency, and every account it touched had to be in that same currency. You could have a US dollar account and a euro account sitting side by side, but no single transaction was allowed to touch both.

That restriction is fine until the day you want to actually do the thing everyone wants: move value from the dollar account to the euro account. And the moment you try, double-entry punches you in the face.

The punch: one transaction, two currencies

Say I want to convert 100 US dollars into euros at a rate of 0.92 euros per dollar. The naive posting looks obvious:

   minus 100 USD   from my dollar account
   plus  92 EUR    to my euro account

Now apply the sacred rule. Do these postings sum to zero? Well... no. They cannot even be added together. 100 dollars and 92 euros are different things. It is like saying "I removed 100 apples and added 92 oranges, therefore nothing changed." That is not a balanced transaction, that is just an assertion that two unrelated numbers are somehow equal because a rate said so. If you let that through, your ledger no longer has a provable invariant. It has a vibe.

So the first real decision of the week was: the zero-sum rule cannot be "all postings sum to zero" anymore. It becomes "for each currency, that currency's postings sum to zero." The dollars must balance among themselves. The euros must balance among themselves. A cross-currency transaction is really two balanced sub-transactions wearing one coat.

But wait: minus 100 USD does not balance to zero by itself, and plus 92 EUR does not either. So how does anything balance? This is where core banking hands you a genuinely elegant, centuries-old trick.

The trick banks actually use: a clearing account

Real banks do not magically turn dollars into euros. Nobody can. What they do is keep a special internal account, historically called a nostro or clearing account, that holds a position in each currency. When you convert dollars to euros, the bank does not shrink your dollars and grow your euros directly. It routes the movement through the clearing account, in two separate, each-fully-balanced currency legs:

Convert 100.00 USD to EUR at 0.92

USD leg (balances in dollars):
  my USD account     -100.00 USD
  FX clearing        +100.00 USD      <-- dollars sum to zero

EUR leg (balances in euros):
  FX clearing         -92.00 EUR
  my EUR account      +92.00 EUR      <-- euros sum to zero

Look at what happened. The dollar postings sum to zero on their own. The euro postings sum to zero on their own. Double-entry is completely intact in each currency. And yet value moved from my dollar account to my euro account. The clearing account is the pivot: it took in 100 dollars and paid out 92 euros. It now holds a position of plus 100 dollars and minus 92 euros. That position is the bank's foreign exchange exposure, and it is a real, honest, on-the-books thing, not a fudge. If you revalue that position at the current rate later, its drift up or down is literally the bank's FX profit or loss.

In go-ledger, the clearing account is a system account, one per tenant per currency, created automatically the first time it is needed, hidden from your normal balance views, and fully expected to sit at a non-zero, often negative, balance forever. That is not a bug. That is the FX book.

So a convert is not two postings, it is four, and the invariant it must satisfy is per-currency zero-sum. My Transaction.Validate() and a database trigger both now group the postings by currency and check each group independently. The books balance in every currency or the transaction does not commit. Nothing gets to lean on "trust me, the rate makes it even."

Now, the exchange rate itself, slowly

Here is the part newcomers never get told plainly. There is no such thing as "the" exchange rate. There is a mid rate, which is the fair midpoint, and then there is the rate you actually get, which is always a little worse. The gap is called the spread, and it is how the house makes money.

Think of a currency booth at the airport. The board shows two numbers: the price they will buy dollars from you at, and the higher price they will sell dollars to you at. You always trade on the side that is worse for you. That difference, the spread, is their margin, and you pay it on every single trade, in both directions. Convert dollars to euros and back to dollars, and you come out with less than you started, even if the mid rate never moved an inch. You paid the spread twice.

I built exactly this. Each currency pair has a mid rate and a spread measured in basis points (a basis point is one hundredth of one percent, so 25 basis points is 0.25 percent). When you convert, the server takes the mid, widens it against you by the spread, and that widened rate is what you get. The customer always lands on the worse side, in both directions. And crucially, the rate comes from the server, never from you: if a client could hand me the rate to use, a client could hand me a rate that steals money. The rate is the house's call, always.

And where does that spread money go? Straight into the clearing account, as a slightly better position for the house. It is not a magic fee that disappears. It is an FX gain sitting right there on the books, auditable, reconcilable, real.

The part that will get you fired if you get it wrong: never touch money with a float

Here is where software meets the razor blade. To convert, you multiply an amount by a rate. The obvious code is amount * rate, where rate is something like 0.92. And that line, written with a floating point number, is a career-ending bug in a payment system.

Floating point numbers cannot represent most decimals exactly. 0.1 + 0.2 in floating point is not 0.3, it is 0.30000000000000004. In most software that is a harmless wart. In money, it is a slow leak: fractions of a cent, appearing and disappearing at random, across millions of transactions, until your ledger no longer sums to zero and nobody can tell you why. This entire project has stored money as whole integers of the smallest unit (cents, not dollars) since week two, precisely to keep floating point out of the money path. It would be a betrayal to let it sneak back in through the exchange rate.

So the rate is not a float either. I store it as a big integer, scaled up. A rate of 0.92 is stored as 92,000,000 (the rate times 100 million). A spread is a whole number of basis points. The conversion is then pure integer arithmetic: multiply the amount by the scaled rate, apply the spread as an integer fraction, and divide back down. No decimals, ever.

There is one catch. Multiplying a large amount by a scaled rate can produce a number too big to fit in a normal 64-bit integer, which would silently wrap around into a wrong, possibly negative, answer. So the multiplication is done in an arbitrary-precision big integer, which cannot overflow, and only the final, safely-rounded result is checked to fit back into a normal integer. If it does not fit, the conversion is rejected loudly rather than wrapping into a lie.

The last decimal: how to round, and where the leftover goes

When you divide to get the final amount, you almost never land on a whole cent. 100 dollars at some rate might come out to 91.7 euros and a fraction. You have to round. And the way you round matters more than you would think.

The tempting choice is "round half up": 0.5 always rounds up. But that has a subtle bias. Over millions of conversions, always rounding halves upward pushes a systematic drift in one direction, a tiny thumb on the scale that adds up. So payment systems use banker's rounding instead: round half to the nearest even number. 2.5 rounds to 2, but 3.5 rounds to 4. Over many values, the ups and downs cancel out, and there is no built-in bias. I implemented banker's rounding carefully, including for negative amounts (postings are often negative), because getting the sign wrong there is its own quiet bug.

And the fraction that gets rounded away, the leftover sliver? It does not vanish. It lands in the clearing account, as part of that account's position. This is the answer to my lifelong question about the missing 34 cents. They are not gone. They moved into the FX book, where they are counted as part of the house's gain or loss, down to the last unit. Money is conserved. I proved it: one of this week's tests converts an amount from dollars to euros and back, and asserts that the total the customer lost equals, exactly, the position now sitting in the clearing accounts. Nothing leaked. It just moved, which is the only thing money is ever allowed to do.

Writing the rate down, because a rate is a promise

One more core-banking habit worth naming. When you convert money, the rate you used is a material fact. If there is ever a dispute ("why did I only get 92 euros?"), the honest answer has to be "here is the exact mid rate, the exact spread, the exact rate applied, the source of that rate, and the moment it was in effect." So every conversion permanently records all of that on the transaction and in the audit log. The rates themselves live in a database table that is append-only: a new rate is a new row, never an overwrite, so the full history of what rate was live at any past moment is always reproducible. And the code that fetches a rate sits behind a small interface, so today it reads a table seeded from configuration, and tomorrow it could pull live rates from a real provider without a single change to the conversion logic.

What I deliberately did not build

This is version one, and scope discipline is the difference between shipping and drowning. There is no live market data feed yet, just a configured rate table with the seam ready for a real provider. There is a single spread per pair, not a real bid and ask sourced from a market. There is no separate profit-and-loss report on the FX position yet; the position simply sits in the clearing accounts, waiting for next week's reporting work to reveal it. Currency conversion goes directly between two currencies, or via a single inversion, not through chains of three. Each of those is a real feature and a real follow-up. None of them is a hole in the invariant, which is the only thing I refuse to compromise.

The lesson I keep relearning

Every week this project teaches me the same thing in a new outfit. The hard part is never the flashy feature. It is defending the invariant while the feature tries to bend it. Multi-currency wanted me to say "the rate makes it balance, trust me." Double-entry does not accept trust, it accepts proof. The clearing account was the way to keep the proof: not by hand-waving that dollars equal euros, but by making the dollars balance as dollars, the euros balance as euros, and letting one honest account hold the difference where anyone can see it.

If you are building anything that moves money, or moves anything that must be conserved, resist every clever shortcut that would make the books balance by assertion instead of by construction. Route the difference through an account you can point at. Keep floating point a hundred miles from the money. And write down the rate, because a rate is a promise, and promises in finance are made to be checked.

The whole thing, clearing accounts and integer rates and all, is at github.com/sohag-pro/go-ledger, and the reasoning is in ADR-014. Next week: nested accounts and reporting, where that quietly accumulating FX position in the clearing accounts finally gets a report that reveals it.