Building a Currency Conversion Service

The first currency conversion bug I ever shipped cost us about 40 euros. A customer in Poland paid an invoice denominated in euros, we converted to zloty for...

Originally published onanselmfowel.com
Building a Currency Conversion Service

The first currency conversion bug I ever shipped cost us about 40 euros. A customer in Poland paid an invoice denominated in euros, we converted to zloty for their statement using a rate we'd cached the day before, and the rate had moved 1.8 percent overnight. Nobody was defrauded, nobody lost real money, but the statement disagreed with the customer's bank by a few zloty and support spent an afternoon explaining why. That is the whole problem in miniature: currency conversion looks like multiplication, and it is actually a question about time, sourcing, rounding, and who is allowed to be wrong.

Building a Currency Conversion Service
Building a Currency Conversion Service

I have now built or rebuilt this service three times at three different companies. The code is never the hard part. The hard part is deciding what the number means, where it came from, and how you prove that six months later when a regulator or a merchant's finance team asks. Here is how I think about it now, and the specific decisions I would not compromise on again.

A rate is not a number, it is a claim

Junior engineers model an exchange rate as a decimal. Senior engineers model it as an assertion with provenance: this pair, at this timestamp, from this source, valid for this purpose. The moment you treat 1.0842 as just a float, you have thrown away the four things that make it defensible. When a dispute lands on your desk, "the rate was 1.0842" is useless. "EUR to USD was 1.0842 at 14:03:11 UTC, sourced from provider X's mid-market feed, used for a display quote, not a settlement" is an answer.

So the first thing I build is not a conversion function. It is a rate record. Every rate we ingest gets stored immutably with its base currency, quote currency, the value, the source identifier, the source's own timestamp, our ingest timestamp, and the rate type. We never overwrite. If provider X sends a correction, that is a new row, not an edit. Storage is cheap and arguments are expensive.

The single best decision we made was making rates append-only. Every reconciliation dispute for two years was resolved by reading a row, not by re-deriving a number nobody could reconstruct.

Mid-market is a lie you tell customers

The rate you see on a search engine is the mid-market rate, the midpoint between what buyers and sellers are quoting. It is a genuinely useful reference and it is also not a price you can transact at. Nobody actually converts a meaningful sum at mid-market. There is always a spread, and pretending otherwise sets up a customer expectation you cannot meet.

My rule is that we always show the mid-market rate for context and always apply an explicit, disclosed margin for the actual conversion. If we charge a 0.5 percent margin on a 10,000 euro conversion, the customer sees the mid-market rate, sees our rate, and can compute the 50 euros of margin themselves. The temptation, especially from the commercial side, is to bury the margin inside a worse-looking rate so it feels free. I have pushed back on that every single time. In regulated payments, a hidden spread is the kind of thing that reads fine in a product meeting and terrible in an audit.

The timestamp that actually matters

There are at least four timestamps floating around any conversion, and confusing them is the source of most reconciliation pain. There is the moment the rate was published by the source. The moment you ingested it. The moment you showed a quote to a customer. And the moment the transaction actually settled. These can be minutes or days apart.

The one I care about most is settlement, because that is the point at which real value moves and the point a regulator considers authoritative. A quote is a promise; a settlement is a fact. We keep both, but we are ruthless about labelling which timestamp governs which purpose. A display quote can use a rate that is a few minutes stale. A settlement cannot, and if the freshest rate we hold is older than a threshold we would rather fail loudly than settle on a guess.

  • Published time — when the source struck the rate. Governs how stale the data is.
  • Ingest time — when we received and stored it. Governs our own latency.
  • Quote time — when we showed a number to a human. Governs what we promised.
  • Settlement time — when value moved. Governs what we owe and what we report.

Rounding is where the money leaks

Floating point has no business anywhere near money. I have inherited a service that used a double for the intermediate conversion and it was quietly losing a fraction of a cent on every transaction, which over a few million transactions became a real number that finance could not tie out. Decimal all the way through, and pick your rounding mode deliberately.

The subtle part is minor units. Most currencies have two decimal places, but not all. Japanese yen has zero. Bahraini dinar has three. Your conversion has to round to the target currency's minor unit, not to a hard-coded two decimals, or you will produce amounts that cannot exist. And you have to decide, and write down, whether you round half-up, half-even, or toward zero, because those differ by real money at scale and a merchant's accountant will notice.

public sealed record Money(long MinorUnits, string Currency);

public static class Converter
{
    // rate is expressed as target per unit of source
    public static Money Convert(Money source, decimal rate, string targetCurrency)
    {
        if (rate <= 0m)
            throw new ArgumentOutOfRangeException(nameof(rate));

        decimal sourceMajor = source.MinorUnits / Pow10(MinorDigits(source.Currency));
        decimal targetMajor = sourceMajor * rate;

        int targetMinorDigits = MinorDigits(targetCurrency);
        decimal scale = Pow10(targetMinorDigits);
        // banker's rounding: half-even, decided once and documented
        long minor = (long)Math.Round(targetMajor * scale, MidpointRounding.ToEven);
        return new Money(minor, targetCurrency);
    }
}

That is deliberately boring code. Currency conversion should be boring code. The interesting decisions all happened before this function ran: which rate, from when, for what purpose, rounded which way.

Feeds fail in boring, dangerous ways

Rate providers do not usually fail by going down. That would be easy; you'd notice. They fail by going stale, sending the same value for six hours because a pipeline on their side jammed, or by fat-fingering a decimal so EUR to USD comes through as 10.842. A naive service ingests that and happily converts a customer's payment at ten times the real rate.

So the ingestion layer needs sanity checks that are almost insultingly simple and catch almost everything. We reject any rate that moved more than a configured percentage from the last accepted value for that pair. We reject rates whose source timestamp hasn't advanced. We alert when a feed goes quiet. None of this is clever, and all of it has saved us from an incident at least once a quarter.

Two providers, and the argument about which one wins

Relying on a single rate provider is a business risk, not just a technical one. We run two. The first time I proposed this, the pushback was cost and complexity, and both concerns were fair. But a single feed means a single point at which your entire pricing can be wrong, and you find out from customers.

The genuinely hard question is not how to fetch from two providers, it is what to do when they disagree. Early on we averaged them, which felt reasonable and was in fact the worst option, because the blended number matched neither source and could not be defended against either. Now we designate a primary and use the secondary as a validator and a fallback. If they disagree beyond a tolerance, we don't average, we hold and alert. A conversion we can explain beats a conversion that is fractionally more accurate but came from a formula nobody outside the team understands.

The audit trail you will be grateful for

Every conversion we perform writes a record that references the exact rate row it used. Not the rate value, the row. So given any converted amount in any statement, anywhere in the system, you can walk back to the precise assertion it was built on: which provider, which timestamp, which margin, which rounding mode. This is the difference between a two-minute answer and a two-day forensic exercise.

This sounds like over-engineering until the first time a merchant's finance team disputes a batch of settlements from three months ago. When that happens, and it will, you either have the trail or you are reconstructing history from application logs and prayer. I have done it both ways. The trail is cheaper.

What I would tell my earlier self

Build the smallest thing that stores rates with full provenance and applies a disclosed margin, and resist every request to make it cleverer. The pressure to add real-time streaming rates, predictive hedging, dynamic margin optimisation, all of that arrives early and all of it is a distraction until the boring core is bulletproof. A conversion service that is correct and explainable is worth more than one that is fast and mysterious.

And treat the commercial conversations about margin as part of the engineering. The technically correct system that hides fees will get you in front of a regulator; the technically correct system that discloses them plainly is one you can defend without flinching. Those are not separate concerns. They are the same system viewed from two chairs.

Anselm Fowel, CTO and fintech architect
Anselm Fowel — CTO & fintech architect

Conclusion

If you take one thing from this, make it the append-only rate record with real provenance. Everything else, the two providers, the sanity checks, the decimal arithmetic, the disclosed margin, hangs off that decision, and none of it works without it. The multiplication was never the job. The job is being able to stand behind a number, months later, in front of someone who has every incentive to prove you wrong, and calmly point at the row that says exactly where it came from.

Chat with us