Your money doesn't all speak the same language. You might earn in dollars, save in euros, and dream in yen. This sprint adds multi-currency supportβbut with a twist: you're always in control.
Most finance apps handle multiple currencies by silently converting everything to a "base currency." Your EUR account shows a USD equivalent. Your reports aggregate everything in dollars. The conversion happens behind the scenes, using rates you didn't choose, at times you don't know about.
We took a different path: no hidden conversions, no automatic rate fetching, no implicit base currency. Every currency stands on its own terms.
The Core Principles
Our multi-currency implementation follows strict principles:
- Local-first, offline-capable: No external APIs. No rate fetching. Works without internet.
- User-controlled rates: You enter exchange rates. You decide when they're effective.
- No hidden base currency: USD doesn't dominate. All currencies are peers.
- Explicit conversion: When conversion happens, you see it. Rate, date, directionβall visible.
- Transactions never mutate: The ledger stays pure. Conversion is always a view concern.
"100 EUR is always 100 EUR. It might equal $108 today and $110 tomorrow, but the 100 EUR never changes."
The Architecture: Layers of Truth
We extended our layered architecture. The ledger (truth layer) remains currency-pure. Conversion lives in the view layer:
βββββββββββββββββββββββββββββββββββββββββββββββ
β VIEW LAYER β
β QueryEngine.executeWithConversion() β
β CurrencyAmount component β
β Multi-currency totals (derived) β
βββββββββββββββββββββββββββββββββββββββββββββββ
β
β reads rates, converts on-demand
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β EXCHANGE RATE LAYER β
β User-provided rates β
β Effective-at timestamps β
β Inverse rate computation β
βββββββββββββββββββββββββββββββββββββββββββββββ
β
β never modifies
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β TRUTH LAYER β
β LedgerService (currency-pure) β
β Transactions always in original currency β
βββββββββββββββββββββββββββββββββββββββββββββββExchange Rates: Explicit and Historical
Each exchange rate has an effectiveAt timestamp. When you need a rate for January 15th, we find the most recent rate effective on or before that date.
// Exchange rate semantics interface ExchangeRate { fromCurrency: 'USD'; toCurrency: 'EUR'; rate: 0.85; // 1 USD = 0.85 EUR effectiveAt: 1705334400; // Jan 15, 2024 source: 'manual'; // Always user-controlled }
If you have USD β EUR but need EUR β USD, we automatically compute the inverse (1/rate). The UI marks these as inverse rates so you always know the source.
Blocking Cross-Currency Transfers
One key decision: transfers between different currencies are blocked. This might seem limiting, but it's intentional:
- Which amount is authoritative? If you transfer $100 and receive β¬85, which side is truth?
- What rate to use? The rate you entered? The rate at transaction time? Something else?
- Hidden fees vanish: Most currency exchanges include fees. A single "transfer" would hide this.
Instead, model currency exchanges as two transactions: an expense from your USD account and an income to your EUR account. This makes the exchange visible and honest.
// Cross-currency "transfer" as two transactions createTransaction({ accountId: usdAccount, amount: -10000, // -$100 expense (to exchange) payee: 'Currency Exchange' }); createTransaction({ accountId: eurAccount, amount: 8500, // +β¬85 income (from exchange) payee: 'Currency Exchange' });
Currency-Scoped Budgets
Budgets now have a currency field. A USD grocery budget only counts USD grocery transactions. EUR spending doesn't affect your USD budget at all.
This is the honest approach. Mixing currencies in budget tracking would require conversion, which would introduce rate variability into what should be a straightforward comparison.
// Budget calculation filters by currency async getCategorySpending( categoryId: string, currency: string, // Only count this currency periodStart: number, periodEnd: number ) { // Sum only transactions in matching currency const transactions = await getTransactions({ categoryId, accountCurrency: currency, dateRange: [periodStart, periodEnd] }); return sum(transactions.map(t => t.amount)); }
View-Layer Conversion
When you want to see totals in a single currency, the QueryEngine can execute views with conversion:
// Request conversion in view execution const result = await queryEngine.executeWithConversion( view, 'EUR', // Target currency for totals Date.now() // As-of date for rate lookup ); // Result includes conversion context result.conversionContext = { targetCurrency: 'EUR', asOf: 1705334400000, conversions: { 'USD': { rate: 0.85, isInverse: false } } }
The conversion context travels with the data so the UI can show exactly what rate was used and when.
Undo Without Side Effects
Exchange rate changes are fully undoable:
-
exchangeRate.createβexchangeRate.revert -
exchangeRate.updateβexchangeRate.revert.update -
exchangeRate.deleteβexchangeRate.restore
Critically: undoing an exchange rate never mutates transactions. Views simply reflect the new rate availability. This maintains the clean separation between truth (ledger) and view (conversions).
The CurrencyAmount Component
We added a UI component that enforces our transparency principle:
<!-- Always shows currency code, never hides --> <CurrencyAmount amount={10000} currency="USD" conversion={{ convertedAmount: 8500, ... }} showConversionDetails={true} /> <!-- Renders: 100.00 USD = 85.00 EUR (rate: 0.8500) -->
The component never hides the currency. It never shows just "$100" when it means "$100 USD = β¬85 EUR." Transparency is baked into the design.
What We Built
Currency Primitive
ISO 4217 codes, symbols, decimal places. User-defined.
Exchange Rate Primitive
Rate pairs with effective dates. Manual or imported.
CurrencyService
CRUD, conversion, rate lookup, formatting utilities.
Account-Level Currency
Each account has a fixed currency. Transfer validation.
View-Layer Conversion
QueryEngine executeWithConversion method.
Currency-Scoped Budgets
Budgets filter spending by currency. No mixing.
Full Undo Support
Exchange rate operations are fully reversible.
CurrencyAmount Component
UI component enforcing transparency.
The Principle
Currency is context, not conversion.
100 EUR is 100 EUR. It exists in its own right. When you need to compare it to USD, we show you both values and the rate we used. Your financial truth stays in its original language.
This is local-first multi-currency: offline capable, user controlled, fully transparent, and never silently converting your money into something it isn't.
Sprint 17 Checklist
// Sprint 17 Verification β Currency primitive exists (ISO 4217) β Exchange rate primitive with effective dates β No automatic FX fetching β User-provided rates only β LedgerService never converts β Conversion happens in QueryEngine/View only β Cross-currency transfers blocked β Budgets are currency-scoped β Undo works for exchange rate operations β CurrencyAmount component shows transparency β Regression suite passes β MULTI_CURRENCY.md documentation exists β Day 17 Dev Journal is present ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ All checklist items complete ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Your money speaks different languages. We don't force it to translate. We show you what it says, in its own words, and when you need a translation, we show you exactly how we did it.
Your currencies. Your rates. Your control.
β The Accelerate Finance Team