Never add two currencies
A cost total should be rebuildable by hand from its lines, to the cent, in any currency. That rules out floats, early rounding, and adding prices before converting them.
- Project
- STP: Vehicle Cost Platform, part 4 of 10
- Published
- October 5, 2026
- Reading time
- 3 min read
Any system that adds up money in several currencies has to decide when to convert, when to round and what type holds the amount, and most money bugs come from making those decisions implicitly. The usual starting point is Martin Fowler's Money pattern: an amount never travels without its currency. STP follows it, and its first rule went further: a total must be rebuildable by hand from its lines, to the cent, in any output currency.
What's stored, and what's computed
Nothing in STP's database is a converted price.
- Supplier offers are stored in the currency the supplier quoted, exactly as received: a screw at 2.50 USD stays 2.50 USD.
- Exchange rates are stored on their own, as three rows (EUR → USD, EUR → GBP, EUR → JPY), each with a revision.
- Conversion happens when a cost is computed, and the result says which rates it used.
The one place converted amounts are written down is a quote, and it stores them next to the original prices and rates that produced them (a number you can replay).
Storing prices in euros would have been simpler to query, but a price stored in euros is a conversion done with a rate that will change, frozen without saying so.
Every conversion goes through EUR, so a USD price wanted in GBP uses two rates, and the result names both, with their revisions:
def convert(amount: Decimal, source: Currency, target: Currency, rates: RateTable) -> Decimal:
"""Through EUR: X -> EUR divides, EUR -> Y multiplies, no rounding in between."""
if source == target:
return amount
# (missing-rate check left out)
in_eur = amount if source == Currency.EUR else CTX.divide(amount, rates[source].value)
return in_eur if target == Currency.EUR else CTX.multiply(in_eur, rates[target].value)
Convert, then add
The tempting order is to sum the prices per currency and convert each subtotal at the end. STP does the opposite: each part's price is converted on its own, multiplied by its quantity, and only then added to the total. With one consistent set of rates both orders give the same total on paper, but only the per-part order keeps every line meaningful on its own. Each line shows its source price, its converted amount and the rate used, and the total is the plain sum of what's displayed.
Choosing between offers follows the same rule: the cheapest valid offer is picked after conversion, not before. In the reference fixture, a screw costs 2 EUR from a French supplier and 2.5 USD from an American one. At 1.08 USD per euro, 2.5 USD is 2.31 EUR, so the French offer wins. Past 1.25 USD per euro, the choice flips on its own.
No floats, no early rounding
- Every amount is a Python
Decimal, with 28 significant digits andROUND_HALF_UP, stored asNUMERIC(28, 9)in PostgreSQL. A binary float can't hold 0.1 or 2.31 exactly, and its errors add up over a 20,000-line BOM. - Rounding happens once, when a total is returned to be displayed, never in between.
- A value that doesn't fit is refused with an explicit error instead of being silently truncated.
That last rule showed up in testing. Extreme generated BOMs, deeply nested with many shared parts, multiply quantities along every path and can exceed 28 digits. The benchmark tool reports that as a failed step, a real result, instead of producing a wrong number.
The UI never does arithmetic
Amounts cross the API as strings and reach the browser as strings, and the UI only formats them. The margin is market price minus cost, so the backend computes it and returns it next to the cost. Even the profitability verdict compares decimal strings digit by digit, without turning them into JavaScript numbers. Digits past the second decimal aren't rounded away on screen either: they're shown smaller and dimmed, so the exact value stays readable.
All of this makes the code more verbose at every boundary, since every amount needs a currency next to it and every conversion a rate revision. In return, any total on any screen can be checked with a calculator, and the reference fixture (162 EUR, 30.6 kg, 13.18 kgCO₂e) hasn't moved by a cent since the first week.