Day 17: Money Speaks Many Languages

multi-currency exchange-rates local-first transparency conversion

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