The project is in a healthy, maintained state
WhittakerTech Midas is a Rails engine that provides elegant multi-currency support through a single polymorphic Coin model. Instead of adding price_cents/price_currency columns to every model, Midas stores all monetary values in one place with a simple has_coins DSL. Includes bank-style currency input fields powered by Stimulus.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

Runtime

~> 1.0
~> 6.19.0
>= 7.1.5.2
 Project Readme

WhittakerTech::Midas

MIT License Ruby 3.2 Rails 7.1 Gem Version CI

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.

Why I Made Midas

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.

Key Capabilities

  • Single canonical Coin model as a unified monetary ledger
  • Declarative monetary attributes via has_coin and has_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 _cents columns

Requirements

  • Ruby 3.2+
  • Rails 7.1+
  • money gem ~> 6.19.0

Installation

Add to your Gemfile:

gem 'whittaker_tech-midas'

Install and run migrations:

bundle install
bin/rails whittaker_tech:midas:install
bin/rails db:migrate

This creates the wt_midas_coins table.

Quick Start

1. Include Bankable in Your Model

class Product < ApplicationRecord
  include WhittakerTech::Midas::Bankable
  
  has_coins :price, :cost, :msrp
end

2. Set Monetary Values

product = 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')

3. Access Values

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"

Usage Guide

The Coin Model

Every monetary value is stored as a Coin with:

  • resource_type / resource_id: Polymorphic association to parent
  • resource_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)

The Bankable Concern

Include Bankable to add monetary attributes to any model:

class Invoice < ApplicationRecord
  include WhittakerTech::Midas::Bankable
  
  has_coins :subtotal, :tax, :total
end

Single Coin

has_coin :price
has_coin :deposit, dependent: :nullify  # Custom dependency

Multiple Coins

has_coins :subtotal, :tax, :shipping, :total

Generated Methods

For 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

Currency Input Field (UI)

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.010.121.2312.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

Currency Configuration

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

Money Gem Configuration

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: '.'
}

Exchange Rates

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 conversion

For production, integrate with an exchange rate API:

How conversion works

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_to

Every 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 made

format(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.format

Historical 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)

Ledger — Double-Entry Bookkeeping

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).

Accounts

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.

Recording a balanced entry

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.

Balances

customer.balance  # => 1000  (raw debit-normal; only counts postings on finalized entries)
revenue.balance   # => -1000

Suspense accounts for out-of-order events

If 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.

What's deferred

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.

Advanced Usage

Multiple Coins on One Resource

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  # => 3

Mixed Currencies

order.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"

Working with Coin Objects Directly

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"

Validations

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
end

Architecture

graph 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]
Loading

Why This Design?

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 times

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

Database Schema

┌─────────────────────────────────────┐
│       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     │
└──────────────────┘

Testing

Run the full test suite:

cd engines/whittaker_tech-midas
bundle exec rspec

With coverage report:

COVERAGE=true bundle exec rspec
open coverage/index.html

Current coverage: 90%+

Development

Setup

cd engines/whittaker_tech-midas
bundle install
cd spec/dummy
bin/rails db:create db:migrate

Dummy App

Test the engine manually:

cd spec/dummy
bin/rails server
# Visit http://localhost:3000

Adding New Features

  1. Write tests first in spec/
  2. Implement in app/
  3. Update README
  4. Run bundle exec rspec
  5. Check coverage with COVERAGE=true bundle exec rspec

Troubleshooting

Exchange rates not working

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)

ArgumentError mentioning "historical" from convert_to

You passed at: without a provider that supports it. Either omit at: or supply using: with a provider implementing #exchange_at.

Input field not formatting

Check that Stimulus is loaded and the controller is registered:

import { MidasCurrencyController } from "whittaker_tech-midas"
application.register("midas-currency", MidasCurrencyController)

Coin not persisting

Ensure the parent record is saved before setting coins:

product = Product.create!  # Must be persisted
product.set_price(amount: 29.99, currency_code: 'USD')

Roadmap

  • 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

Contributing

  1. Fork the repository
  2. Create your feature branch
  3. Write tests
  4. Implement your feature
  5. Submit a pull request

License

MIT License. See MIT-LICENSE for details.

Credits

Built by WhittakerTech

Powered by: