WhittakerTech::Midas
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
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
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 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)
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.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
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
- Write tests first in
spec/ - Implement in
app/ - Update README
- Run
bundle exec rspec - 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
- Fork the repository
- Create your feature branch
- Write tests
- Implement your feature
- Submit a pull request
License
MIT License. See MIT-LICENSE for details.
Credits
Built by WhittakerTech
Powered by: