Back to FintelliHubPayments

Multi-currency by default

What building for ZWG, USD and ZAR simultaneously teaches you about resilient financial software — and why bolting a second currency on later never really works.

By Fintellisys team#multi-currency#payments#fx#africa
Multi-currency by default

Almost every financial system starts single-currency, because almost every financial system starts somewhere with one currency. Then it meets a market like ours, where a customer might pay a USD price with ZAR notes and receive ZWG change, and the retrofit begins.

The retrofit never really finishes. You can spot a bolted-on implementation in about a minute: there is a column called amount and another called currency, and somewhere in the codebase two amounts get added together without anybody checking that the second column matches. The bug is not theoretical. It is one of the most common serious defects we find in systems we inherit.

Designing for it from the start costs little and changes three things structurally.

The first is that an amount is never a number. It is a pair — value and currency — and the two are inseparable at every layer: the database, the API, the domain model, the report. Once you allow a bare decimal to represent money anywhere in the system, someone will eventually add two of them. Make the type itself refuse. A sum of mixed currencies should be a compile-time or validation-time failure, not a silent arithmetic result.

The second is that every converted figure carries its rate and its timestamp. Not the rate you would use now — the rate that was actually applied, at the moment it was applied, from the source you applied it from. Without that, you cannot explain a historical balance to a customer, reproduce last quarter's statement, or answer a regulator asking why two reports covering the same period disagree. Storing only the converted total is the single most expensive shortcut in this whole domain, because the information you discarded cannot be recovered later.

The third is that you need to decide, explicitly and early, what the system's unit of account is. Every institution has one — the currency the books are actually kept in, that the general ledger balances in, that the board looks at. Transactions can be denominated in anything, but reporting has to resolve to something. Systems that never made this decision end up with a reporting layer that quietly picks a currency per query, which is how you get two dashboards that disagree and nobody able to say which is right.

Then there are the things that only show up in a market with real volatility.

Rounding stops being cosmetic. When one unit of account is worth several thousand of another, the rounding rule at the boundary is a policy question with revenue implications, and it needs to be written down and applied in exactly one place. We have seen a rounding inconsistency between the till and the ledger generate a daily variance large enough to trigger an investigation every single morning.

Rates go stale, and staleness needs a behaviour. If the day's rate has not been published, or the device has been offline through the update, the system must do something defined — refuse, use the last known rate and flag it, or fall back to a policy rate. All three are defensible. Doing whatever happens to be in the cache is not.

And denominations are not infinitely divisible. A price computed correctly to two decimals may be impossible to actually tender in cash, in a market where the smallest available note is large. Cash rounding is a separate rule from arithmetic rounding, and conflating them produces tills that cannot balance.

None of this is exotic. It is the ordinary condition of doing business in much of the world, and it is only ever treated as an edge case by people who have not had to do it.

Build the pair type on day one. Store the rate you used. Name your unit of account. Everything after that is detail — and everything before it is rework.