I build a multi-currency personal-finance app. This is one of the
first bugs I had to design my way out of — and it's one almost every
multi-currency app I've tried still has.
The symptom
You spent 200 zł on groceries in March. Your base currency is euros.
In March the app showed that as, say, €46. You open the app in July and
that same March transaction now reads €43 — even though nothing about
March changed. Multiply that across every foreign transaction and your
"total spent in March" quietly drifts every single day.
That's not a rounding quirk. It means your financial history is being
rewritten with today's exchange rate, and you can't trust any
number older than right now.
Why it happens
The naive multi-currency data model looks reasonable:
transaction {
amount: 200
currency: "PLN"
date: "2026-03-14"
}
Enter fullscreen mode Exit fullscreen mode
Then, to show a total in the user's base currency, you convert on read:
const eur = amount * liveRate(currency, "EUR");
Enter fullscreen mode Exit fullscreen mode
liveRate returns today's rate. So the March transaction is re-priced
with the July rate every time it's rendered. Balances update live —
which is correct for what you hold right now — but the same logic
leaks onto historical transactions, where it's just wrong. A cost
you actually incurred at March's rate should read at March's rate
forever.
The principle
Convert live for balances. Snapshot for history.
A transaction is an event that happened at a moment in time. The
exchange rate that applied is a fact about that moment, not a live
value. So capture it once, at ingest, and never recompute it:
transaction {
amount: 200
currency: "PLN"
date: "2026-03-14"
rate_to_base: 0.2312 // EUR per PLN on the value date, frozen
amount_base: 46.24 // optional: precomputed, for fast sums
}
Enter fullscreen mode Exit fullscreen mode
Now historical totals are a plain SUM(amount_base) — deterministic,
and March stays €46.24 in July, next year, forever. Only current
account balances get the live rate, because those really are
"what is this worth right now."
The details that bite
Which date's rate? Use the transaction's value date (when the
money actually moved), not the booking/import date. Banks can book days
later; pricing at import time reintroduces drift.
Rates aren't published every day. The ECB (I use their Frankfurter
feed) publishes on TARGET business days — no weekends, no holidays. A
Saturday transaction has no same-day rate. Carry forward the last
published rate rather than skipping or interpolating; that's what the
banks do too.
Some currencies aren't in the feed. The ECB doesn't publish UAH,
for example. You need a secondary source for the long tail, normalized
into the same base so your snapshot is consistent regardless of which
feed a given currency came from.
Store the rate, not just the converted amount. If you only store
amount_base, you can never let the user change their base currency
without lying again. Keep the original amount + currency + date,
and the snapshotted rate. To switch base currency you re-derive from
the original at each transaction's historical rate — which means you
also need historical rates on hand, not just today's. (Storing the
rate against a single pivot currency like EUR and deriving cross-rates
keeps this cheap.)
Idempotency. Banks re-send transactions. Snapshot the rate keyed to
the transaction's identity + value date so a re-sync never re-prices an
existing row.
The takeaway
If you're building anything that sums money across currencies over
time — budgeting, accounting, portfolio, expenses — decide explicitly,
per number, whether it's a live value or a historical one.
Balances are live. Everything that already happened is history, and
history needs its rate frozen at the moment it occurred. Get this wrong
and every chart you draw is quietly fiction.
I'm building EveryPenny, a calm
multi-currency money tracker for people whose money lives across more
than one country — it connects EEA banks and Monobank in Ukraine and
shows one honest total. It's what pushed me to get this right. Happy to
go deeper on the rate-sourcing side in the comments.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.