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

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, 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):


# 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 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 and encapsulated in lib/money/currency.rb. The class memoizes instances to prevent duplicate objects for the same currency code:

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 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:

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, 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:

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 model persists historic rates with a composite unique index on from_currency, to_currency, and date:

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 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, requiring implementations of fetch_exchange_rate and fetch_exchange_rates.

Currency Conversion Logic

The Money#exchange_to method in 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
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 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 combining BigDecimal amounts with Money::Currency metadata
  • Arithmetic Safety: lib/money/arithmetic.rb implements coercion and strict type checking to prevent float precision errors
  • Formatting: 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 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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →