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).

Stored in PostgreSQLComputed in EUR, not storedcomponent_offersscrewFR supplier2.00EURalready EUR2.00 EURscrewUS supplier2.50USD2.50 ÷ 1.082.31 EURbattery moduleUS supplier4,210.00USD4,210.00 ÷ 1.083,898.15 EURinverterJP supplier168,000JPY168,000 ÷ 1601,050.00 EURexchange_ratesEUR → USD1.08rev 8EUR → GBP0.85rev 3EUR → JPY160rev 5each result names the rates it used,with their revisionsOnly a quote writes euro totals down, next to the original prices and rates it used.
Prices are stored in the currency the supplier quoted, rates on their own. The euro amounts exist only in a computation's result.

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.

EUR/USD rate1.001.101.201.301.40tie at 1.25French supplieroffer: 2.00 EUR2.00 EUR✓ pickedUS supplieroffer: 2.50 USD2.50 ÷ 1.08 = 2.31 EUR0 EUR
The same screw from two suppliers, compared after conversion. Once the dollar is weak enough, 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 and ROUND_HALF_UP, stored as NUMERIC(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.

PostgreSQLNUMERIC(28, 9)2.500000000 USDbackendDecimal, 28 digits2.5 ÷ 1.08 =2.314814814814814814814814815APIJSON string"2.314814814814814814814814815"browserstring, formatted2.314814814814…floats:none, at any stagerounding:once, when the API returns a total (162.00 EUR); never in between
One converted price from the database to the screen. It's never a float, and the extra digits are dimmed on screen, not rounded away.

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.