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:
- Check the database cache for an existing rate
- If missing, query the registered provider (currently Synth) via
Provider::Registry.for_concept(:exchange_rates) - Persist the fetched rate when
cache: true(default) or return transiently whencache: 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:
- Returns self if currencies match
- Retrieves rate via
store.find_or_fetch_rate(defaulting toExchangeRate) - Raises
Money::ConversionErrorif no rate exists - 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.rbcombiningBigDecimalamounts withMoney::Currencymetadata - Arithmetic Safety:
lib/money/arithmetic.rbimplements coercion and strict type checking to prevent float precision errors - Formatting:
lib/money/formatting.rbleverages Rails helpers for locale-aware currency display - Exchange Rates: Provider-based architecture with
ExchangeRatemodel caching andExchangeRate::Providedconcern for on-demand fetching - Persistence: Integer cents storage via
balance_centscolumns, 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →