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.4+
  • 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]

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: