Skip to content

Usage

The Bankable concern

Include Bankable in any ActiveRecord model to give it monetary attributes:

class Product < ApplicationRecord
  include WhittakerTech::Midas::Bankable

  has_coins :price, :cost, :msrp
end

has_coin and has_coins

# Single coin
has_coin :price

# Multiple coins at once
has_coins :subtotal, :tax, :shipping, :total

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

dependent: accepts any ActiveRecord has_one dependency option (:destroy, :delete, :nullify, :restrict_with_exception, :restrict_with_error). Default: :destroy.


Generated methods

For every has_coin :name, Bankable generates:

Method Returns Notes
name Coin or nil The persisted Coin record
name_coin Coin or nil The has_one association directly
name_amount Money or nil Calls coin.amount
name_format String or nil Calls coin.amount.format
set_name(amount:, currency_code:) Coin Creates or updates the Coin
midas_coins ActiveRecord::Relation All coins on this resource (shared)

Setting values

product = Product.create!

# From a float (major units — dollars)
product.set_price(amount: 29.99, currency_code: 'USD')

# From a Money object
product.set_price(amount: Money.new(2999, 'USD'), currency_code: 'USD')

# From an integer (minor units — cents)
product.set_price(amount: 2999, currency_code: 'USD')

Integer vs. Numeric semantics

When you pass an Integer, it is treated as minor units (cents). When you pass a Float or BigDecimal, it is treated as major units (dollars) and scaled by the currency's decimal places.


Reading values

product.price             # => #<WhittakerTech::Midas::Coin ...>
product.price_amount      # => #<Money @fractional=2999 @currency="USD">
product.price_format      # => "$29.99"

product.price.currency_code   # => "USD"
product.price.currency_minor  # => 2999
product.price.major           # => BigDecimal("29.99")
product.price.decimals        # => 2

Coin arithmetic

All arithmetic returns new frozen Coins. Coins are never mutated.

Addition and subtraction

a = Coin.value(1000, 'USD')  # $10.00
b = Coin.value(500,  'USD')  # $5.00

a + b   # => Coin($15.00)
a - b   # => Coin($5.00)
-a      # => Coin(-$10.00)
a.negate # same as -a

Currencies must match. A mismatched operation raises ArgumentError.

Multiplication and modulo

a * 3      # => Coin($30.00)
a % 3      # => Coin($0.01)  # remainder of 1000 % 3 = 1 cent

Division with rounding policies

Integer division can produce a non-integer result. Specify a policy:

coin = Coin.value(1000, 'USD')  # $10.00

coin.divide(3, rounding_policy: :round)    # => Coin($3.33)  (333.33 → 333)
coin.divide(3, rounding_policy: :ceil)     # => Coin($3.34)
coin.divide(3, rounding_policy: :floor)    # => Coin($3.33)
coin.divide(3, rounding_policy: :bankers)  # => Coin($3.33)

# Shorthand convenience methods
coin.divide_round(3)
coin.divide_ceil(3)
coin.divide_floor(3)
coin.divide_bankers(3)

Equality

Value-based: two Coins are equal when they share the same currency and minor-unit amount.

Coin.value(2999, 'USD') == Coin.value(2999, 'USD')  # => true
Coin.value(2999, 'USD') == Coin.value(2999, 'EUR')  # => false

# Works in Sets and as Hash keys
set = Set.new([Coin.value(100, 'USD'), Coin.value(100, 'USD')])
set.size  # => 1

Formatting with Presenter

The present method accepts a strftime-like pattern:

coin = Coin.value(2999, 'USD')  # $29.99

coin.present('%t')           # => "$29.99"          (%t = formatted total)
coin.present('%s%M')         # => "$29.99"          (%s = symbol, %M = major)
coin.present('%M %c')        # => "29.99 USD"
coin.present('%m cents')     # => "2999 cents"      (%m = raw minor units)

Token reference

Token Renders
%t Full formatted amount (symbol + number)
%m Raw minor-unit integer
%M Major units as decimal string
%c ISO currency code
%s Currency symbol
%n Number only (symbol stripped)
%u Custom units label (pass units: option)
%p Custom per-exact label (pass per_exact:)
~ Approximate marker: or empty string
%% Literal percent sign

Presenter options

coin.present('~%t', approx: true)             # => "≈$29.99"
coin.present('%M %u', units: 'per kg')        # => "29.99 per kg"
coin.present('%p / unit', per_exact: '0.30')  # => "0.30 / unit"

Parsing inputs

Coin.parse accepts several input types:

# From Money
Coin.parse(Money.new(2999, 'USD'))               # => Coin(2999 USD)

# From float (major units)
Coin.parse(29.99, currency_code: 'USD')          # => Coin(2999 USD)

# From string with embedded symbol
Coin.parse('$29.99')                             # => Coin(2999 USD)

# From string with explicit code
Coin.parse('29.99', currency_code: 'USD')        # => Coin(2999 USD)

# Identity: a Coin is returned as-is
Coin.parse(existing_coin)                        # => existing_coin

Per-unit pricing with Allocation

Allocation computes per-unit and total pricing from a bulk Coin without storing any sub-minor-unit values:

bulk = product.price            # $100.00 for a pack of 6
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
alloc.price(qty: 6)  # => Coin($100.00) — reconstitutes the original

The Coin is unchanged. Allocation is a non-persisted value object.


Currency input field

Render a bank-style currency input in any form:

<%= 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 border-gray-300 text-right',
                         placeholder: '0.00' } %>

  <%= f.submit 'Save' %>
<% end %>

Typing behaviour: Digits accumulate from the right.

Keystrokes Display Hidden (minor)
1 0.01 1
12 0.12 12
123 1.23 123
1234 12.34 1234

The form submits the hidden minor-unit field, which is fed directly to set_price(amount: params[:price_minor].to_i, ...).


Zero values and nil

# Constructing a zero Coin directly
zero = Coin.zero('USD')        # => Coin(0 USD)

# Bankable returns nil if a coin has not been set yet
product.price       # => nil  (before set_price is called)
product.price_amount # => nil
product.price_format # => nil

Guard against nil before performing arithmetic:

price = product.price || Coin.zero('USD')

Working with multiple coins

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

# Access all coins on this record
order.midas_coins.count  # => 3
order.midas_coins.map(&:resource_role)
# => ["subtotal", "tax", "total"]

# Arithmetic across coins
order.subtotal + order.tax  # => Coin($108.50)

Custom validations

Coins are persisted by set_* methods. Validate the parent model:

class Product < ApplicationRecord
  include WhittakerTech::Midas::Bankable
  has_coin :price

  validate :price_must_be_positive

  private

  def price_must_be_positive
    return unless price

    errors.add(:price, 'must be positive') unless price.currency_minor > 0
  end
end