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
- Test suite with >90% coverage
- Zero schema duplication—no proliferation of
_centscolumns
- Ruby 3.4+
- 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" (if exchange rates configured)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 for currency conversion:
# 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:
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" (with exchange rate)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:
Money.default_bank.add_rate('USD', 'EUR', 0.85)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: