# How Maybe Finance Implements Multi-Currency Support with the Money Gem

> Explore Maybe Finance's multi-currency architecture. Learn how they use the Money gem and a pluggable provider system for robust exchange rate management and database caching.

- Repository: [Maybe/maybe](https://github.com/maybe-finance/maybe)
- Tags: architecture
- Published: 2026-03-07

---

**Maybe Finance handles multi-currency support through a custom Money value object that encapsulates BigDecimal amounts and currency metadata, while delegating exchange rate lookups to a pluggable provider system with database caching.**

The open-source personal finance application Maybe (maybe-finance/maybe) requires robust multi-currency support to handle accounts, transactions, and balances across different currencies. Rather than using a third-party gem, the project implements a lightweight, domain-specific Money architecture that separates currency concerns from persistence logic while maintaining precision through BigDecimal arithmetic.

## Core Money Value Object

The foundation of Maybe's multi-currency support lives in [`lib/money.rb`](https://github.com/maybe-finance/maybe/blob/main/lib/money.rb), which defines an immutable value object storing a **BigDecimal amount** and a **Money::Currency** instance.

### Instantiation and Default Currency

The Money class constructor validates input types and delegates currency resolution to `Money::Currency.new(currency)`:

```ruby

# Default currency is USD (configurable via Money.default_currency)

Money.new(1000)                     # => $1,000.00

Money.new(1000, :eur)               # => €1,000.00

Money.new(BigDecimal('12.34'), 'JPY')

```

The constructor implementation at lines 33-37 of [`lib/money.rb`](https://github.com/maybe-finance/maybe/blob/main/lib/money.rb) ensures that only `Money`, `Numeric`, or `BigDecimal` types are accepted, preventing precision loss from float arithmetic.

### Currency Metadata Management

Currency definitions are loaded from [`config/currencies.yml`](https://github.com/maybe-finance/maybe/blob/main/config/currencies.yml) and encapsulated in [`lib/money/currency.rb`](https://github.com/maybe-finance/maybe/blob/main/lib/money/currency.rb). The class memoizes instances to prevent duplicate objects for the same currency code:

```ruby
c = Money::Currency.new(:eur)
c.symbol        # => "€"

c.default_precision # => 2

```

This metadata drives both formatting rules and arithmetic precision requirements across the application.

## Arithmetic and Type Coercion

The [`lib/money/arithmetic.rb`](https://github.com/maybe-finance/maybe/blob/main/lib/money/arithmetic.rb) module implements Ruby's coercion protocol, allowing Money objects to participate in mathematical operations with both other Money instances and plain numerics.

### Supported Operations

The module defines `+`, `-`, `*`, `/`, and unary negation with strict type checking:

```ruby
Money.new(100) + Money.new(50)   # => $150.00

Money.new(100) * 2                # => $200.00

- Money.new(30)                   # => -$30.00

```

Because `Money` implements `coerce`, expressions like `5 + Money.new(10)` resolve correctly, with the numeric value being promoted to a Money instance using the default currency.

## Locale-Aware Formatting

Presentation logic resides in [`lib/money/formatting.rb`](https://github.com/maybe-finance/maybe/blob/main/lib/money/formatting.rb), which delegates to Rails' `number_to_currency` helper while respecting currency-specific defaults.

### Formatting Examples

The `format` method (aliased as `to_s`) merges currency metadata with optional locale overrides:

```ruby
Money.new(1000.12, :eur).to_s               # => "€1,000.12"

Money.new(1000.12, :eur).format(locale: :nl) # => "€ 1.000,12"

```

This approach ensures consistent formatting across the application while supporting internationalization requirements.

## Exchange Rate Architecture

Currency conversion is handled through a provider-based architecture that separates rate retrieval from the Money value object.

### The Exchange Rate Model

The [`app/models/exchange_rate.rb`](https://github.com/maybe-finance/maybe/blob/main/app/models/exchange_rate.rb) model persists historic rates with a composite unique index on `from_currency`, `to_currency`, and `date`:

```ruby
rate = ExchangeRate.find_or_fetch_rate(
  from: "USD", to: "EUR", date: Date.today
)

# => #<OpenStruct from:"USD", to:"EUR", rate:1.2, date:...>

```

### Provider Integration

The `ExchangeRate::Provided` concern in [`app/models/exchange_rate/provided.rb`](https://github.com/maybe-finance/maybe/blob/main/app/models/exchange_rate/provided.rb) implements the lookup logic:

1. Check the database cache for an existing rate
2. If missing, query the registered provider (currently Synth) via `Provider::Registry.for_concept(:exchange_rates)`
3. Persist the fetched rate when `cache: true` (default) or return transiently when `cache: false`

The provider interface is defined in [`app/models/provider/exchange_rate_concept.rb`](https://github.com/maybe-finance/maybe/blob/main/app/models/provider/exchange_rate_concept.rb), requiring implementations of `fetch_exchange_rate` and `fetch_exchange_rates`.

### Currency Conversion Logic

The `Money#exchange_to` method in [`lib/money.rb`](https://github.com/maybe-finance/maybe/blob/main/lib/money.rb) (lines 42-55) orchestrates conversion:

1. Returns self if currencies match
2. Retrieves rate via `store.find_or_fetch_rate` (defaulting to `ExchangeRate`)
3. Raises `Money::ConversionError` if no rate exists
4. Returns new Money instance with converted amount

```ruby
Money.new(1000, :usd).exchange_to(:eur) # => €1,200.00 (assuming 1 USD = 1.2 EUR)

```

## Database Persistence Strategy

Maybe avoids storing serialized Money objects in the database, instead persisting **integer cents** in columns like `balance_cents`.

### Migration to Integer Storage

The migration [`db/migrate/20240206031739_replace_money_field.rb`](https://github.com/maybe-finance/maybe/blob/main/db/migrate/20240206031739_replace_money_field.rb) replaced the old Money column with an integer `balance_cents` column. This approach:

- Eliminates serialization overhead and versioning issues
- Maintains currency-agnostic storage (currency code stored separately if needed)
- Allows database-level aggregations and indexing on monetary values

Application models convert between `Money` objects and integer cents at the boundary, ensuring business logic always operates with precise `BigDecimal` values while the database stores efficient integers.

## Summary

- **Money Value Object**: Immutable class in [`lib/money.rb`](https://github.com/maybe-finance/maybe/blob/main/lib/money.rb) combining `BigDecimal` amounts with `Money::Currency` metadata
- **Arithmetic Safety**: [`lib/money/arithmetic.rb`](https://github.com/maybe-finance/maybe/blob/main/lib/money/arithmetic.rb) implements coercion and strict type checking to prevent float precision errors
- **Formatting**: [`lib/money/formatting.rb`](https://github.com/maybe-finance/maybe/blob/main/lib/money/formatting.rb) leverages Rails helpers for locale-aware currency display
- **Exchange Rates**: Provider-based architecture with `ExchangeRate` model caching and `ExchangeRate::Provided` concern for on-demand fetching
- **Persistence**: Integer cents storage via `balance_cents` columns, migrating away from serialized Money objects

## Frequently Asked Questions

### How does Maybe handle currency conversion without losing precision?

Maybe stores all monetary amounts as `BigDecimal` objects within the `Money` class, never using floating-point arithmetic. During currency conversion, the `exchange_to` method retrieves rates from the `ExchangeRate` model and multiplies the BigDecimal amount, ensuring no precision loss occurs during calculations.

### What exchange rate provider does Maybe use?

Currently, Maybe integrates with **Synth** as the default exchange rate provider through the `Provider::Registry` system. The `ExchangeRate::Provided` concern looks up rates using `Provider::Registry.for_concept(:exchange_rates)`, which returns the configured provider implementing the `ExchangeRateConcept` interface.

### Why does Maybe store money as integer cents rather than decimal columns?

The migration [`db/migrate/20240206031739_replace_money_field.rb`](https://github.com/maybe-finance/maybe/blob/main/db/migrate/20240206031739_replace_money_field.rb) moved storage to integer `balance_cents` columns to avoid serialization complexity and database vendor differences in decimal handling. This approach stores values currency-agnostically, enables efficient database aggregations, and eliminates versioning issues with serialized Ruby objects while the application layer handles `Money` object conversion.

### Can Maybe handle arithmetic between different currencies?

No, the `Money::Arithmetic` module in [`lib/money/arithmetic.rb`](https://github.com/maybe-finance/maybe/blob/main/lib/money/arithmetic.rb) explicitly prevents operations between different currencies to avoid implicit conversion errors. Attempting to add or subtract Money objects with different currency codes raises an error, forcing developers to explicitly convert currencies using `exchange_to` before performing cross-currency calculations.