A Rails engine for elegant monetary value management with multi-currency support. Midas provides a single source of truth for all currency values in your application, eliminating schema bloat and unifying currency behavior.
Midas was created because monetization code becomes one of the most fragile parts of a Rails application. Teams duplicate currency logic across dozens of models, leading to rounding inconsistencies, schema bloat, and costly refactors during growth phases. Midas centralizes all monetary behavior into a single, predictable source of truth. This design keeps your pricing, billing, and financial reporting consistent across the entire system.
- Single canonical
Coinmodel as a unified monetary ledger - Declarative monetary attributes via
has_coinandhas_coins - Money-safe arithmetic backed by RubyMoney’s precision library
- Automatic minor-unit conversion for all input types (int, float, Money)
- Multi-currency support with configurable exchange rates
- Headless currency input UI for form builders
- Additive double-entry bookkeeping via
Ledger(accounts + balanced, immutable postings) — see Ledger below - Test suite with >90% coverage
- Zero schema duplication—no proliferation of
_centscolumns
- Ruby 3.2+
- Rails 7.1+
- money gem ~> 6.19.0
Add to your Gemfile:
gem 'whittaker_tech-midas'Install and run migrations:
bundle install
bin/rails whittaker_tech:midas:install
bin/rails db:migrateThis creates the wt_midas_coins table.
class Product < ApplicationRecord
include WhittakerTech::Midas::Bankable
has_coins :price, :cost, :msrp
endproduct = Product.create!
# From float (dollars)
product.set_price(amount: 29.99, currency_code: 'USD')
# From Money object
product.set_price(amount: Money.new(2999, 'USD'), currency_code: 'USD')
# From integer (cents)
product.set_price(amount: 2999, currency_code: 'USD')product.price # => Coin object
product.price_amount # => Money object (#<Money @cents=2999 @currency="USD">)
product.price_format # => "$29.99"
product.price_in('EUR') # => "€26.85"Every monetary value is stored as a Coin with:
resource_type/resource_id: Polymorphic association to parentresource_label: Identifies which money attribute (e.g., "price")currency_code: ISO 4217 code (USD, EUR, JPY, etc.)currency_minor: Integer value in minor units (cents, pence)
Include Bankable to add monetary attributes to any model:
class Invoice < ApplicationRecord
include WhittakerTech::Midas::Bankable
has_coins :subtotal, :tax, :total
endhas_coin :price
has_coin :deposit, dependent: :nullify # Custom dependencyhas_coins :subtotal, :tax, :shipping, :totalFor each has_coin :price, you get:
| Method | Returns | Example |
|---|---|---|
price |
Coin object | product.price |
price_coin |
Coin association | product.price_coin |
price_amount |
Money object | Money<2999 USD> |
price_format |
Formatted string | "$29.99" |
price_in(currency) |
Formatted conversion | "€26.85" |
set_price(amount:, currency_code:) |
Creates/updates coin | Returns Coin |
midas_coins |
All coins on resource | product.midas_coins.count |
Midas provides a headless Stimulus-powered currency input with bank-style typing:
<%= form_with model: @product do |f| %>
<%= midas_currency_field f, :price,
currency_code: 'USD',
label: 'Product Price',
wrapper_html: { class: 'mb-4' },
input_html: {
class: 'rounded-lg border-gray-300 text-right',
placeholder: '0.00'
} %>
<%= f.submit %>
<% end %>Bank-Style Typing:
User types 1234 → displays as 0.01 → 0.12 → 1.23 → 12.34
Features:
- Automatic decimal handling based on currency
- Hidden field stores minor units (cents)
- Style with Tailwind, Bootstrap, or custom CSS
- Backspace removes rightmost digit
Define currency-specific settings via I18n:
# config/locales/midas.en.yml
en:
midas:
ui:
defaults:
decimal_count: 2
currencies:
USD:
decimal_count: 2
symbol: "$"
JPY:
decimal_count: 0
symbol: "¥"
BTC:
decimal_count: 8
symbol: "₿"Configure Money gem behavior (recommended):
# config/initializers/money.rb
Money.locale_backend = nil # or :i18n for i18n support
Money.default_bank = Money::Bank::VariableExchange.new
Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN
Money.default_formatting_rules = {
display_free: false,
with_currency: false,
no_cents_if_whole: false,
format: '%u%n', # symbol before amount
thousands_separator: ',',
decimal_mark: '.'
}Set up exchange rates on Money.default_bank as usual:
# In your app
Money.default_bank.add_rate('USD', 'EUR', 0.85)
Money.default_bank.add_rate('EUR', 'USD', 1.18)
# Now conversions work
product.price_in('EUR') # Automatic conversionFor production, integrate with an exchange rate API:
Coin conversion is provider-agnostic. By default it wraps whatever bank is
set on Money.default_bank (via Coin::Converter::BankProvider), so any of
the gems above work unmodified. You can also convert directly:
coin = product.price
coin.convert_to('EUR') # => new, persisted Coin in EUR
coin.exchange_to('EUR') # => alias for convert_toEvery conversion — whether via convert_to, exchange_to, #{name}_in, or
Coin#format(to:) — writes an immutable WhittakerTech::Midas::Exchange
audit row recording the from/to coins, the rate used, the provider
source, and the timestamp (at). This is a write-only audit log, not a
rate cache: convert_to never reads past Exchange rows back to resolve a
rate, it always asks the provider fresh.
result = coin.convert_to('EUR')
exchange = WhittakerTech::Midas::Exchange.last
exchange.from # => Coin copy of the original value (USD)
exchange.to # => the converted result (== `result`)
exchange.rate # => BigDecimal rate used
exchange.source # => "money:Money::Bank::VariableExchange"
exchange.at # => Time the conversion was madeformat(to:) converts on every call. If you need the same converted
value multiple times, convert once and reuse the result instead of calling
format(to:) repeatedly — each call performs a live conversion and writes a
new Exchange row:
# Avoid — converts and audits twice
coin.format(to: 'EUR')
coin.format(to: 'EUR')
# Prefer — convert once, format many times
converted = coin.convert_to('EUR')
converted.amount.formatHistorical rates. Passing at: requires a provider that implements
#exchange_at(money, currency_code, at:) — the default BankProvider does
not, since Money::Bank::VariableExchange has no historical capability.
Passing at: against the default provider raises ArgumentError. Supply a
custom provider via using: for historical support:
coin.convert_to('EUR', at: 3.months.ago, using: my_historical_provider)Custom providers. Any object responding to #exchange(money, currency_code)
and #name can be passed as using: to override the default bank-backed
provider — useful for testing or wiring in a rate API directly:
coin.convert_to('EUR', using: my_provider)Since 0.4.0. Ledger is additive to Coin/Bankable, not a replacement — most monetary
attributes should keep using has_coin/has_coins. Reach for Ledger when you need a full,
audited double-entry trail (billing, subscriptions, anything where "why is this balance what it
is" needs a real answer).
An Ledger::Account is either a system account (no owner — e.g. a per-currency suspense or
revenue account, disambiguated by slug) or an owned account (a polymorphic owner, e.g. a
Customer):
revenue = WhittakerTech::Midas::Ledger::Account.create!(kind: :revenue, slug: 'revenue', currency_code: 'USD')
customer = WhittakerTech::Midas::Ledger::Account.create!(kind: :asset, owner: current_customer, currency_code: 'USD')
# Per-currency suspense account, for posting out-of-order events against
suspense = WhittakerTech::Midas::Ledger::Account.suspense_for('USD')kind is one of asset, liability, equity, revenue, expense, suspense.
Ledger::Entry.record! is the only sanctioned way to create an entry — it's the one call
that guarantees the result balances:
WhittakerTech::Midas::Ledger::Entry.record!(
currency_code: 'USD',
occurred_at: Time.current,
lines: [
{ account: customer, direction: :debit, amount: 1000 },
{ account: revenue, direction: :credit, amount: 1000 }
]
)An entry with mismatched debits/credits, a mixed-currency line, a zero-amount posting, or no
lines at all raises ActiveRecord::RecordInvalid and rolls back entirely — nothing partial is
ever left behind.
Entries and their postings are immutable once the entry finalizes. Attempting to add,
destroy, or reattach an amount to a posting on an already-finalized entry raises
WhittakerTech::Midas::Ledger::UnbalancedEntryError.
customer.balance # => 1000 (raw debit-normal; only counts postings on finalized entries)
revenue.balance # => -1000If an external event arrives out of order (e.g. a refund webhook before its charge), post it
against Account.suspense_for(currency_code) — reclassifying later is just recording a second
balanced entry that debits suspense and credits the now-known correct account; entries are
immutable, so reclassification is never a mutation of the original.
Monthly partitioning of the postings table, a DB-level balance-invariant backstop,
reclassification tooling/aging alerts, and multi-currency entries are intentionally out of scope
for this release — see CHANGELOG.md.
order = Order.create!
order.set_subtotal(amount: 100.00, currency_code: 'USD')
order.set_tax(amount: 8.50, currency_code: 'USD')
order.set_total(amount: 108.50, currency_code: 'USD')
order.midas_coins.count # => 3order.set_subtotal(amount: 100, currency_code: 'USD')
order.set_shipping(amount: 850, currency_code: 'EUR')
order.subtotal_format # => "$100.00"
order.shipping_format # => "€8.50"
order.shipping_in('USD') # => "$10.00"coin = product.price
coin.currency_code # => "USD"
coin.currency_minor # => 2999
coin.amount # => Money object
coin.amount.format # => "$29.99"
coin.exchange_to('EUR') # => Money object in EUR
coin.format(to: 'EUR') # => "€26.85"class Product < ApplicationRecord
include WhittakerTech::Midas::Bankable
has_coin :price
validate :price_must_be_positive
private
def price_must_be_positive
if price_amount && price_amount.cents <= 0
errors.add(:price, "must be positive")
end
end
endgraph TD
A[Model with Bankable] --> B[has_coin :price]
B --> C[Coin Record]
C --> D[Money Object]
D --> E[Formatting/Display]
D --> F[Conversions / Exchange Rates]
Problem: Traditional Rails apps duplicate currency logic everywhere:
# ❌ Schema bloat - every model needs these columns
add_column :products, :price_cents, :integer
add_column :products, :price_currency, :string
add_column :invoices, :subtotal_cents, :integer
add_column :invoices, :subtotal_currency, :string
# ...repeated dozens of timesSolution: Midas uses a polymorphic Coin model as a single source of truth:
# ✅ One table, unlimited monetary attributes
create_table :wt_midas_coins do |t|
t.references :resource, polymorphic: true
t.string :resource_label # "price", "cost", "tax", etc.
t.string :currency_code
t.integer :currency_minor
end┌─────────────────────────────────────┐
│ wt_midas_coins │
├─────────────────────────────────────┤
│ id BIGINT │
│ resource_type STRING │ ─┐
│ resource_id BIGINT │ ─┤ Polymorphic
│ resource_label STRING │ ─┘
│ currency_code STRING(3) │
│ currency_minor BIGINT │
│ created_at TIMESTAMP │
│ updated_at TIMESTAMP │
└─────────────────────────────────────┘
▲
│ has_many :midas_coins
│
┌────────┴─────────┐
│ Any Model with │
│ Bankable │
└──────────────────┘
Run the full test suite:
cd engines/whittaker_tech-midas
bundle exec rspecWith coverage report:
COVERAGE=true bundle exec rspec
open coverage/index.htmlCurrent coverage: 90%+
cd engines/whittaker_tech-midas
bundle install
cd spec/dummy
bin/rails db:create db:migrateTest the engine manually:
cd spec/dummy
bin/rails server
# Visit http://localhost:3000- Write tests first in
spec/ - Implement in
app/ - Update README
- Run
bundle exec rspec - Check coverage with
COVERAGE=true bundle exec rspec
Make sure you've configured exchange rates on Money.default_bank (the
default provider raises whatever error the underlying bank raises, e.g.
Money::Bank::UnknownRate, if a rate is missing):
Money.default_bank.add_rate('USD', 'EUR', 0.85)You passed at: without a provider that supports it. Either omit at: or
supply using: with a provider implementing #exchange_at.
Check that Stimulus is loaded and the controller is registered:
import { MidasCurrencyController } from "whittaker_tech-midas"
application.register("midas-currency", MidasCurrencyController)Ensure the parent record is saved before setting coins:
product = Product.create! # Must be persisted
product.set_price(amount: 29.99, currency_code: 'USD')- Install generator (
rails g midas:install) - Add coins generator (
rails g midas:add_coins Product price cost) - Built-in exchange rate fetching
- Coin versioning for audit trails
- ViewComponent integration
- Stripe/LemonSqueezy integration examples
- Fork the repository
- Create your feature branch
- Write tests
- Implement your feature
- Submit a pull request
MIT License. See MIT-LICENSE for details.
Built by WhittakerTech
Powered by: