ADR-0025: Offline FX conversion for foreign-currency aggregation
- Status: Accepted
- Date: 2026-08-15
- Deciders: maintainers
- Related: money.md · ADR-0009 · ADR-0006 · ADR-0015
Context
Transactions store their amount in the original currency
(TransactionEntity.amountMillis + currency, ADR-0009). A foreign-currency
charge — e.g. a S$45.00 travel spend — is kept in SGD by design
(money.md → Currency.currencyNearestTo). Totals and reports use the settings
currency (SettingsRepository.KEY_CURRENCY, default MYR) as the display/base
currency, but it was only ever used for formatting, never for conversion.
Aggregation summed raw amountMillis ignoring currency, so a S$45.00
transaction was added as 4500 to a MYR total — numerically wrong.
The app is offline-first with no app code path that performs network I/O (ADR-0015; MapLibre later merged network permissions, see its Amendment), so live fetching of market rates is out of scope.
Decision
- The user curates an FX-rate map in Settings:
foreign code → rate(base major units per 1 foreign major unit), stored in the encryptedsettingstable underKEY_FX_RATES(a serialized JSONMap<String, String>). - New pure-Kotlin
common/Fx.ktconverts minor units between currencies withBigDecimal+HALF_UP(movePointLeft(fromDigits) * rate * movePointRight(baseDigits)), preserving the no-floating-point invariant. - Home, Reports, Budget and Transactions observe the base currency and the
rate map together as one
SettingsRepository.observeCurrencyConfig()snapshot and fold each transaction throughFx.toBasebefore summing / taking the max, so category slices, totals and budget comparisons all land in base units. - A transaction that cannot be converted (unknown currency or missing
rate) is excluded from the aggregate — never summed numerically raw —
and a flag (
hasUnconvertedForeign) is set so the UI can surface a footnote.
Consequences
- Mixed-currency months now aggregate correctly;
S$45.00becomes MYR using the configured rate instead of4500. - Budget
spentis compared againstlimitMillisin the same base units, so the comparison is meaningful. - Rates go stale over time; converting historical rows at the current rate is an accepted approximation for a personal tracker. Per-month rate snapshots are deferred.
- Realtime market FX rates are rejected (offline-first, ADR-0015).
Alternatives considered
- Hardcoded static rates in
common/— goes stale with no way for the user to correct it. - Live FX lookup — requires network, conflicts with ADR-0015.
Amendment (2026-10-19): the base currency owns its rates
- Rates are entered as units of the current base, so switching the base and
clearing
KEY_FX_RATEShappen in oneSettingsDaotransaction (switchCurrencyAndClearRates), and a same-base selection is a no-op. - Consumers read base + rates as ONE snapshot (
SettingsRepository.observeCurrencyConfig, a single query) so a new base can never be observed paired with old-base rates. The Settings editor is a single owned state machine: exactly one save/switch/reload operation at a time, completions applied only to the draft revision that started them, a dirty draft quarantined (never relabelled) when the observed base changes, and an explicit reload to adopt the new base. - A rate save is guarded inside that transaction against the base it was loaded
with and is refused (
StaleBase) rather than reinterpreted. - User-entered rates are validated (
Fx.validateRate): positive plain decimal, ≤ 6 significant decimal places (trailing zeros ignored), magnitude ≤ 1,000,000, length ≤ 32; scientific notation and non-positive values are rejected. Out-of-policy stored/imported rates are treated as missing, never clamped.SettingsRepository.saveFxRatesre-validates and refuses an invalid map, and the genericput/putAllpath rejectsKEY_FX_RATES. The editor renders enabled currencies plus any currency with an invalid draft rate (including a hidden one) so Save can never be blocked by an unreachable field.