Skip to content

Architecture

Midas replaces the scattered-columns anti-pattern with a single polymorphic ledger. Every monetary value in your application — regardless of which model owns it — is stored as a Coin record.


The problem Midas solves

The conventional Rails approach is to add per-attribute currency columns to every model that needs money:

# ❌ Schema bloat — each monetary attribute doubles your 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
add_column :invoices,  :tax_cents,          :integer
add_column :invoices,  :tax_currency,       :string
# ...repeated across dozens of models

Consequences:

  • Schema migrations needed for every new monetary attribute
  • Rounding and conversion logic duplicated across models
  • No single place to enforce currency rules or add audit hooks
  • Currency columns crowd schema views and make queries harder to read

The Midas solution: one table

# ✅ One table, unlimited monetary attributes on any model
create_table :midas_coins do |t|
  t.references :resource, polymorphic: true, null: false
  t.string  :resource_role,   null: false, limit: 64  # "price", "tax", "deposit"
  t.string  :currency_code,   null: false, limit: 3   # "USD", "EUR", "JPY"
  t.bigint  :currency_minor,  null: false              # integer minor units (cents)
end

A unique index on [resource_id, resource_type, resource_role] ensures one Coin per attribute per record.

Database schema

┌──────────────────────────────────────────┐
│              midas_coins                 │
├──────────────────────────────────────────┤
│ id             BIGINT        PK          │
│ resource_type  VARCHAR       ─┐          │
│ resource_id    BIGINT        ─┤ Polymorphic
│ resource_role  VARCHAR(64)   ─┘          │
│ currency_code  VARCHAR(3)                │
│ currency_minor BIGINT                    │
│ created_at     TIMESTAMP                 │
│ updated_at     TIMESTAMP                 │
└────────────────┬─────────────────────────┘
                 │ has_many :midas_coins, as: :resource
       ┌─────────┴──────────┐
       │   Any model with   │
       │     Bankable       │
       └────────────────────┘

Core components

WhittakerTech::Midas
│
├── Coin                         ← persisted ActiveRecord model
│   ├── Coin::Arithmetic         ← immutable math (+, -, *, /, %, negate)
│   ├── Coin::Bidi               ← Unicode RTL/LTR isolation
│   ├── Coin::Converter          ← placeholder (not yet implemented)
│   ├── Coin::Presenter          ← strftime-like formatting grammar
│   ├── Coin::Parser             ← input coercion (Money / Numeric / String → Coin)
│   └── Coin::Allocation         ← per-unit pricing value object
│
├── Bankable                     ← concern: has_coin / has_coins DSL
│
└── FormHelper / JS controller   ← bank-style currency input field

Coin — the canonical value

Coin is an ActiveRecord model, but it behaves like a value object:

  • Identity = currency_code + currency_minor (not the database id)
  • Immutable by convention — arithmetic operations return new frozen Coins
  • No sub-minor unitscurrency_minor is always an integer (cents, not fractions of cents)
  • No embedded logic — formatting, pricing, and conversion live in separate modules

Field semantics

Field Type Notes
resource_type String Polymorphic owner class name, e.g. "Product"
resource_id Integer Polymorphic owner primary key
resource_role String Attribute name, e.g. "price". Unique per resource.
currency_code String ISO 4217 3-letter code. Stored uppercase.
currency_minor Integer Amount in minor units (e.g. cents for USD)

Module breakdown

Arithmetic

Coin::Arithmetic

All arithmetic is exact, integer arithmetic on minor units. No floating point arithmetic is performed on stored values; however, division uses floating point internally before applying a rounding policy to produce an integer result.

Operation Policy required? Notes
+ No Currency must match
- No Currency must match
* No Multiplier must be Integer
% No Remainder after integer division
negate No Sign reversal
divide Yes Returns rounded Coin

Division is the only operation that can produce a non-integer result when dividing integer minor units. A rounding policy resolves this:

Policy Behaviour
:round Half-up (standard commercial)
:ceil Always rounds up
:floor Always rounds down (truncates)
:bankers Round half-to-even (IEEE 754)

Convenience methods are generated for each policy:

coin.divide_round(3)   # divide with :round policy
coin.divide_ceil(3)    # divide with :ceil policy
coin.divide_floor(3)
coin.divide_bankers(3)

Presenter

Coin::Presenter

A strftime-like grammar for rendering Coins as strings. Pure — no mutation, rounding, or conversion.

Token Renders
%t Formatted total (symbol + amount)
%m Raw minor units integer
%M Major units as decimal string
%c ISO currency code
%s Currency symbol
%n Number only (symbol stripped)
%u Custom units label (option: units:)
%p Custom per-exact label
%~ Approximate marker ( or empty)
%% Literal %
coin = Coin.value(2999, 'USD')
coin.present('%s%M')           # => "$29.99"
coin.present('%t (%c)')        # => "$29.99 (USD)"
coin.present('%~%t', approx: true)  # => "≈$29.99"
coin.present('%M %u', units: 'kg')  # => "29.99 kg"

Parser

Coin::Parser

Coerces input of any supported type into a Coin. The Coin.parse class method delegates here.

Input type Semantic
Coin Returned unchanged
Money money.cents + money.currency.iso_code
Numeric Treated as major units; requires currency
String Strips non-numeric chars; requires currency or embedded symbol
Coin.parse(29.99, currency_code: 'USD')    # => Coin(2999 USD)
Coin.parse(Money.new(2999, 'USD'))         # => Coin(2999 USD)
Coin.parse('$29.99')                       # => Coin(2999 USD)

Allocation

Coin::Allocation

A non-persisted value object for per-unit pricing. Created via coin.allocate(per:, rounding_policy:).

bulk = Coin.value(10_000, 'USD')   # $100.00 for 6 items
alloc = bulk.allocate(per: 6, rounding_policy: :ceil)

alloc.value          # => Coin($16.67) — per-unit cost
alloc.price(qty: 2)  # => Coin($33.34) — total for 2 units

Bidi

Coin::Bidi

Wraps currency strings in Unicode directional-isolate marks (U+2066 LRI, U+2067 RLI, U+2069 PDI) to prevent the Unicode Bidirectional Algorithm from reordering symbol + number sequences in RTL UIs (Arabic, Hebrew, etc.).

Direction defaults to LTR for all currencies. Override per-currency:

WhittakerTech::Midas.currency_directions['ILS'] = :rtl
WhittakerTech::Midas.currency_directions['AED'] = :rtl

Converter (reserved)

Coin::Converter is a placeholder module. It raises NotImplementedError today. When implemented it will handle exchange rates, historical rates, and regulatory rounding. Use the Money gem's exchange rate API directly for now.


Bankable concern

Bankable is an ActiveSupport::Concern that wires the Coin ledger into any ActiveRecord model.

When you call has_coin :price, Bankable:

  1. Creates a scoped has_one :price_coin association (filters on resource_role: "price")
  2. Defines price, price_amount, price_format, and set_price instance methods
  3. The parent model gets has_many :midas_coins for accessing all its coins together
class Invoice < ApplicationRecord
  include WhittakerTech::Midas::Bankable

  has_coins :subtotal, :tax, :total
end

Amount coercion in set_*

set_price(amount:, currency_code:) accepts three input forms:

Input Meaning
Money Minor units taken directly from .cents
Integer Treated as minor units
Float/Numeric Treated as major units; scaled by 10 ** decimals_for(iso)

FormHelper and Stimulus controller

midas_currency_field renders a three-part field group:

<div data-controller="midas-currency"
     data-midas-currency-currency-value="USD"
     data-midas-currency-decimals-value="2">
  <!-- Visible display input (formatted major units) -->
  <input data-midas-currency-target="display" type="text" />
  <!-- Hidden field that holds minor units (submitted with form) -->
  <input data-midas-currency-target="hidden" type="hidden" name="product[price_minor]" />
  <!-- Hidden field for the currency code -->
  <input type="hidden" name="product[price_currency]" value="USD" />
</div>

Bank-style typing: digits accumulate from the right. Typing 1, 2, 3, 4 displays 0.01 → 0.12 → 1.23 → 12.34. Backspace removes the last digit.


Configuration

table_namespace — PostgreSQL schema namespacing

# config/initializers/midas.rb
WhittakerTech::Midas.table_namespace = 'finance'
# => table becomes finance.coins

Requires PostgreSQL. Creates the table as <namespace>.coins inside the named schema.

currency_directions — RTL support

WhittakerTech::Midas.currency_directions['ILS'] = :rtl
WhittakerTech::Midas.currency_directions['AED'] = :rtl

I18n — decimal places and symbols

# config/locales/midas.en.yml
en:
  midas:
    ui:
      defaults:
        decimal_count: 2
      currencies:
        JPY:
          decimal_count: 0
        BTC:
          decimal_count: 8

Design principles

Immutability

Every Coin operation that returns a value returns a new, frozen Coin. The receiver is never mutated. This makes Coins safe to pass across service boundaries and cache without defensive copying.

Closure

Arithmetic operations are closed over Coin — they always return a Coin, never a raw integer or float. This prevents accidental loss of currency context.

No sub-minor units

currency_minor is always an integer. There are no fractions of cents stored anywhere. Division applies a rounding policy before creating the result Coin.

Separation of concerns

Each module has one job:

  • Arithmetic — math
  • Presenter — display strings
  • Parser — input coercion
  • Allocation — per-unit pricing
  • Bidi — RTL/LTR isolation
  • Converter — (future) exchange rates