Project

minting

0.0
The project is in a healthy, maintained state
Library to manipulate currency values
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

Gem Version CI Test Coverage Documentation RubyCritic Score GitHub last commit License

Minting

Fast, precise, and developer-friendly money handling for Ruby.

Status: Minting 2.3 is released. The core API (Money, Currency, formatting, parsing) is stable.

price = Money.from(19.99, 'USD')       #=> [USD 19.99]
tax   = price * 0.08                   #=> [USD 1.60]
total = price + tax                    #=> [USD 21.59]

total.to_s                             #=> "$21.59"

Quickstart

Get started in 60 seconds:

# Add to your Gemfile
gem "minting"

# Or install directly
# gem install minting

# Require the gem
require "minting"

# Create money objects
price = Money.from(19.99, "USD")
tax_rate = 0.08
tax = price * tax_rate
total = price + tax

# Format as a string
puts total.to_s # => "$21.59"

# Parse from a string
money = Money.parse("$15.99")
puts money # => "$15.99"

# Allocate money proportionally
total = Money.from(100, "USD")
shares = total.allocate([1, 2, 3]) # => [16.67, 33.33, 50.00]

# Split into N equal parts
parts = total.split(3) # => [33.34, 33.33, 33.33]

What's new since 2.0

New Features

  • Crypto currency support: Opt-in YAML-backed definitions for ~25 popular coins (BTC, ETH, SOL, ...). Use Money::Currency.register_crypto('BTC', 'ETH') to register, or Money::Currency.crypto_currencies to inspect available definitions.
  • Currency.registered_currencies — public access to all registered currencies (frozen hash)
  • Money.from_hash(hash) — deserializer symmetric with to_hash, accepts { currency:, amount: }
  • Money#integral — returns the whole-unit part of the amount (complement to #fractional). #to_i is now an alias of #integral.
  • %<dsymbol>s format placeholder — uses currency.disambiguate_symbol (e.g. "US$", "C$", "A$") when available, falling back to the primary symbol.
  • Compiled formatting: Formatting is now compiled into reusable lambdas at the class level — 1.4–2.2x formatting speedup depending on scenario.
  • Locale-aware formatting: locale: kwarg on Money#format / #to_fs, supports per-locale decimal/thousand separators and format templates via Mint.locale_backend. Works seamlessly with Rails I18n.
  • Faster startup: World currencies are now preloaded at gem initialization, eliminating lazy-loading overhead and mutex contention.

Bugfixes

  • Money#fractional now returns a signed value matching the amount's sign (previously always positive for negative amounts). The invariant integral * multiplier + fractional == subunits now holds for all amounts.
  • Money#initialize now calls .to_r on the amount, guaranteeing @amount is always a Rational. Fixes a hash/eql? contract violation for zero-subunit currencies and an ArgumentError in Integer#to_d.

Breaking Changes

  • Money#clamp no longer accepts Numeric bounds — only Money or nil. Previously price.clamp(0, 100) was silently accepted; now it raises ArgumentError. Use price.clamp(0.dollars, 100.dollars) instead. This prevents accidental misuse where a bare number is passed as a variable.
  • Rounding mode symbols renamed to match Rational#round half: parameter: :half_up → :up, :half_down → :down, :half_even → :even
  • Mint.parse and Mint.parse! removed — use Money.parse and Money.parse!
  • Mint.with_rounding removed — use Money.with_rounding
  • Mint.world_currencies removed — use Currency.world_currencies
  • Money#mint removed — use Money#copy_with
  • Money::Currency is the canonical name to access the Currency class
  • Money#format formatter_class: kwarg removed — Formatter is now the sole formatter implementation
  • Money#to_json and Money.from_json — moved to attribute-money companion gem

Removed

  • Money::Formatter2 — removed; Formatter is the sole implementation
  • Money#to_json and Money.from_json — moved to attribute-money companion gem

Amounts are stored as Rational, so there's no floating-point drift — 0.1 + 0.2 problems simply don't happen here, at any scale.

Table of contents

  • Quickstart
  • Why Minting
  • What's new since 2.0
  • How it compares
  • Installation
  • Usage
    • Creating & comparing money
    • Formatting
    • Integral & fractional parts
    • Parsing strings
    • Currency lookup
    • Crypto currencies
    • Locale formatting
  • API notes
  • Optional top-level Money and Currency
  • Rails integration
  • Roadmap
  • License

Why Minting

Minting started as a personal project to learn what it actually takes to build and maintain a real open source Ruby gem — not exactly as a reaction against any existing library. That origin shows in how it's built: it's grown deliberately, with an emphasis on correctness and a clean API.

What it's become along the way:

  • Exact by construction — amounts are Rational internally, rounded to the currency's subunit only when needed. No silent precision loss from repeated arithmetic.
  • No Rails dependency — Minting is a plain Ruby gem. Use it in a script, a Sinatra app, a background job runner, or a Rails app — your choice, not the gem's.
  • Formatting that doesn't fight you — Kernel.format-style templates, per-sign formats (parentheses for negatives), explicit sign and magnitude placeholders, and a pluggable locale hook.
  • Built for real-world currency handling — 150+ ISO-4217 currencies, correct subunit handling (JPY has none, KWD has three), proportional allocation/split that doesn't lose cents to rounding.
  • Measured, not assumed, performance — see the Performance Guide for actual benchmarks rather than claims.
  • Rails-ready without being Rails-only — pair with the companion MoneyAttribute gem for ActiveRecord type casting, validators, and form helpers.

How it compares

A few structural differences from the money gem (and money-rails), for anyone evaluating both:

Minting Money
Internal representation Rational BigDecimal (float-backed input coercion)
Rails integration via money_attribute gem via money-rails gem
Exchange rates Pluggable provider architecture (planned) Built-in bank/exchange abstraction
Currency data Ships with the gem Ships with the gem

Installation

bundle add minting

Or add to your Gemfile:

gem 'minting'

Usage

Creating & comparing money

Money.from is the canonical constructor. The older Mint.money factory and Numeric#mint shortcut are deprecated; use Money.from and Numeric#to_money instead. Named shortcuts such as dollars, euros, and reais remain available.

Currency arguments accept currency code strings or Currency objects. Symbols such as :USD are not supported.

require 'minting'

ten = Money.from(10, 'USD')            #=> [USD 10.00]

1.dollar == Money.from(1, 'USD')       #=> true
ten = 10.dollars                       #=> [USD 10.00]
4.to_money('USD')                      #=> [USD 4.00]

# Comparisons
ten == 10.dollars                      #=> true
ten == Money.from(10, 'EUR')           #=> false
ten > Money.from(9.99, 'USD')          #=> true

# Zero equality semantics
# Any zero amount is treated as equal, regardless of currency
Money.from(0, 'USD') == Money.from(0, 'EUR')   #=> true
Money.from(0, 'USD') == 0                      #=> true
Money.from(0, 'USD') == 0.0                    #=> true

# Non-zero numerics are not equal to Money objects
Money.from(10, 'USD') == 10                    #=> false

# Ranges and enumeration are supported
1.dollar..10.dollars                      #=> [USD 1.00]..[USD 10.00]
(1.dollar..3.dollars).step(1.dollar).to_a #=> [[USD 1.00], [USD 2.00], [USD 3.00]]

# Clamping to a range
price = Money.from(50, 'USD')
min_price = Money.from(75, 'USD')

price.clamp(price, Money.from(100, 'USD'))  #=> [USD 50.00]  (returns self, no new object)
price.clamp(0.dollars, 25.dollars)           #=> [USD 25.00]  (clamped to max)
price.clamp(min_price, 100.dollars)          #=> [USD 75.00]  (clamped to min)

Formatting

price = Money.from(9.99, 'USD')

# Use direct format strings
price.format                                  #=> "$9.99"
price.format('%<amount>d')            #=> "9"
price.format('%<symbol>s%<amount>f')  #=> "$9.99"
price.format('%<symbol>s%<amount>+f') #=> "$+9.99"
(-price).format('%<amount>f')         #=> "-9.99"
(-price).format('%<sign>s%<symbol>s%<magnitude>f') #=> "-$9.99"

# Format with padding
price_in_euros = Money.from(12.34, 'EUR')

price.format('--%<amount>7d')               #=> "--      9"
price.format('  %<amount>10f %<currency>s') #=> "        9.99 USD"
(-price).format('  %<amount>10f')           #=> "       -9.99"
price_in_euros.format('%<symbol>2s%<amount>+10f')    #=> " €    +12.34"

# Integral & fractional parts
price.format('%<integral>d %<fractional>d/100')        #=> "9 99/100"
Money.from(0.99, 'USD').format('%<integral>d dollars and %<fractional>02d cents')
#=> "0 dollars and 99 cents"
(-Money.from(0.99, 'USD')).format('%<sign>s%<integral>d.%<fractional>02d')
#=> "-0.99"

# Per-sign Hash format (e.g. accounting parentheses for losses)
loss = Money.from(-1234.56, 'USD')
loss.format( { negative: '(%<symbol>s%<amount>f)' })  #=> "($1,234.56)"
Money.from(0, 'BRL').format( { zero: '--' })          #=> "--"
fmt = { positive: '%<symbol>s%<amount>f', negative: '(%<symbol>s%<amount>f)', zero: '--' }
Money.from(1234.56, 'USD').format( fmt)               #=> "$1,234.56"

# Disambiguated symbol (e.g. "US$" vs "C$" vs "A$")
Money.from(10, 'USD').format('%<dsymbol>s%<amount>f')  #=> "US$10.00"
Money.from(10, 'CAD').format('%<dsymbol>s%<amount>f')  #=> "C$10.00"
Money.from(10, 'EUR').format('%<dsymbol>s%<amount>f')  #=> "€10.00" (falls back to symbol)

# Hash serialization
price.to_hash   #=> {currency: "USD", amount: "9.99"}

Integral & fractional parts

price.integral    #=> 9         # whole-unit part
price.fractional  #=> 99        # fractional part (subunits within one unit)
price.subunits    #=> 999       # total amount in smallest unit
price.to_i        #=> 9         # alias of integral

Mint::Money.from_subunits(999, 'USD')  #=> [USD 9.99]
Mint::Money.from_subunits(1234, 'JPY') #=> [JPY 1234]  # subunit 0 -> no scaling

# No currency (ISO 4217 XXX)
Mint::Money.no_currency(100) #=> [XXX 100]
Mint::Money.no_currency(0)   #=> [XXX 0]

# Proportional allocation and split
ten = 10.dollars
ten.split(3)                 #=> [[USD 3.34], [USD 3.33], [USD 3.33]]
ten.allocate([1, 2, 3])       #=> [[USD 1.67], [USD 3.33], [USD 5.00]]

Parsing strings

Money.parse('$19.99')           #=> [USD 19.99]
Money.parse('19,99 €')          #=> [EUR 19.99]
Money.parse('1.234,56', 'EUR')  #=> [EUR 1234.56]
Money.parse('USD 1,234.56')     #=> [USD 1234.56]

Notes:

  • Pass a currency code when the string has no symbol or code.
  • The optional default_currency: argument is a default, not an override: a code or symbol embedded in the string takes precedence (Money.parse('10 EUR', default_currency: 'USD') returns EUR). The positional currency argument is deprecated.
  • 1,234 means 1234, not 1.234, and 1,23 means 1.23, not 123.
  • 1,234.00 is unambiguous (thousands + decimal).
  • Parsing is separator-positional rather than locale-aware. For currencies with three decimal places, a single comma followed by three digits is read as a thousands separator: Money.parse('KWD 861,949') means 861949 KWD. Use a period decimal (KWD 861.949) or both separators (KWD 1.234,567) when the intended value has three fractional digits.
  • Pass the input source's decimal separator explicitly when a single separator followed by three digits is ambiguous: Money.parse('123,456', 'EUR', decimal: ',') means 123.456, while decimal: '.' means 123456. The default currency can be passed positionally or as default_currency: 'EUR'. When the input is an integer with grouping, pass thousand: explicitly instead: Money.parse('123,456', 'USD', thousand: ',') means 123456. When both options are supplied, they define the roles independently, so a decimal separator that is absent from the input is also valid: Money.parse('123,456', 'USD', decimal: ':', thousand: ',') means 123456. When both options are omitted, the positional rules above remain in effect.
  • Accounting negatives like ($1.23) or (USD 10.00) are supported — the parser detects parentheses and negates the amount.
  • Ambiguous symbols like $ resolve by currency priority (currently USD).
  • The parser scans all uppercase words for registered codes, so spurious non-currency words before the real code are correctly ignored: Money.parse("MAX 10.00 USD") yields [USD 10.00].

Currency lookup

# All registered currencies (currently 164 built-in ISO 4217 + custom)
Money::Currency.registered_currencies.size                 #=> 164
Money::Currency.registered_currencies.each { |code, c| puts "#{code}: #{c.name}" }

# Built-in ISO 4217 currencies (before custom registrations)
Money::Currency.world_currencies.size    #=> 164

# By ISO code (direct hash lookup, string only)
Money::Currency.for_code('USD')        #=> #<Currency code="USD" ...>

# By display symbol (highest-priority currency for ambiguous symbols)
Money::Currency.for_symbol('$')        #=> #<Currency code="USD" ...>
Money::Currency.for_symbol('R$')       #=> #<Currency code="BRL" ...>
Money::Currency.for_symbol('€')        #=> #<Currency code="EUR" ...>

Polymorphic currency resolution — Currency.resolve also accepts objects that implement #to_currency or #currency_code:

class Product
  def currency_code = 'USD'
end

Money::Currency.resolve(Product.new)  #=> #<Currency code="USD" ...>
Money::Currency.resolve!(Product.new) # raises Mint::UnknownCurrency if code is unknown

#to_currency takes precedence when both methods exist. It must return a Currency object; #currency_code must return a String. Wrong types raise ArgumentError.

Crypto currencies

Minting ships with opt-in definitions for ~25 popular crypto currencies (BTC, ETH, SOL, ...). They are not registered by default — use register_crypto to enable them:

Money::Currency.register_crypto('BTC', 'ETH', 'SOL')

Money.parse("0.01 BTC")    #=> [BTC 0.01000000]
Money.from(1, 'ETH')      #=> [ETH 1.000000000000000000]

Money::Currency.crypto_currencies lists all available definitions without registering:

Money::Currency.crypto_currencies.each { |c| puts "#{c.code}: #{c.name}" }
# BTC: Bitcoin
# ETH: Ethereum
# SOL: Solana
# ...

register_crypto raises KeyError on duplicate codes and ArgumentError on unknown codes. Register all at once:

# Register all at once:
Currency.register_all_crypto   # raises KeyError on any conflict

Locale formatting

Minting doesn't ship built-in locale data, but the Mint.locale_backend hook lets you wire in locale-specific decimal/thousand separators and format templates:

LOCALE_DATA = {
  'en'    => { decimal: '.', thousand: ',', format: '%<symbol>s%<amount>f' },
  'pt'    => { decimal: ',', thousand: '.', format: '%<symbol>s%<amount>f' },
  'pt-BR' => { decimal: ',', thousand: '.', format: '%<symbol>s%<amount>f' },
  'de'    => { decimal: ',', thousand: '.', format: '%<amount>f %<currency>s' },
  'fr'    => { decimal: ',', thousand: ' ', format: '%<amount>f %<symbol>s' },
  'ja'    => { decimal: '.', thousand: ',', format: '%<symbol>s%<amount>f' },
}.freeze

Mint.locale_backend = ->(locale) { LOCALE_DATA[locale.to_s] || {} }

Money.from(1234.56, 'USD').format(locale: :en)  #=> "$1,234.56"
Money.from(9.99, 'BRL').format(locale: 'pt')    #=> "R$9,99"
Money.from(9.99, 'EUR').format(locale: :de)     #=> "9,99 EUR"
Money.from(9.99, 'EUR').format(locale: 'fr')    #=> "9,99 €"
Money.from(9.99, 'USD').format(locale: :ja)     #=> "$9.99"

Pass locale: as a keyword to format / to_fs. Accepts both symbols (:en, :'pt-BR') and strings ('pt-BR', 'en-US') — passed through as-is, matching Rails' I18n.locale convention. The backend returns a hash with :decimal, :thousand, and optionally :format (defaults to '%<symbol>s%<amount>f'). String and symbol keys are interchangeable. Return {} or nil for unknown locales — defaults apply.

Rails I18n key names (:separator, :delimiter) are also accepted — no mapping needed:

Mint.locale_backend = ->(locale = nil) {
  I18n.with_locale(locale || I18n.default_locale) do
    I18n.t('number.currency.format', default: {})
  end
}

Minting names take precedence when both are present (e.g. { decimal: '.', separator: ',' } uses '.').

Arity-0 callables (-> { ... }) are called without arguments and work unchanged:

Mint.locale_backend = -> { { decimal: ',', thousand: '.' } }
Money.from(9.99, 'BRL').format  #=> "R$9,99"

API notes

Exact amounts — Amounts are stored as Rational and rounded to the currency subunit.

Sign and magnitude placeholders — %<amount> retains its sign. %<magnitude> always renders the absolute value, while %<sign> renders + for positive values, - for negative values, and an empty string for zero. Use a width of 1 to reserve the sign column for zero values:

format = '%<sign>1s%<symbol>s%<magnitude>f'
Money.from(12.34, 'BRL').format(format, decimal: ',')  #=> "+R$12,34"
Money.from(-12.34, 'BRL').format(format, decimal: ',') #=> "-R$12,34"
Money.from(0, 'BRL').format(format, decimal: ',')      #=> " R$0,00"

Rounding modes — Wrap operations in Money.with_rounding(mode) to change how amounts are rounded to the subunit:

Money.with_rounding(:down) { Money.from(1.005, 'USD') }   #=> [USD 1.00]
Money.with_rounding(:even) { Money.from(1.015, 'USD') }   #=> [USD 1.02]
Money.with_rounding(:up)   { Money.from(1.005, 'USD') }   #=> [USD 1.01]

Modes: :up (default), :down, :even. Applies to construction, parsing, copy_with, split, and allocate. Restores the previous mode when the block exits, even on exception.

Performance note: Rounding-mode support is not loaded by default — require 'minting' uses the fastest possible rounding (equivalent to :up) with zero dispatch overhead. The first call to Money.with_rounding activates the rounding dispatch in Currency#normalize_amount, adding ~10–35 ns per money creation or mutation. If your application never uses custom rounding modes, there is no performance cost.

Common API workflows

Parsing failures — Money.parse returns nil for invalid input or an unresolved currency. Money.parse! raises ArgumentError instead:

Money.parse('bad', 'USD')       #=> nil
Money.parse!('bad', 'USD')      #=> raises ArgumentError

The optional currency argument is a default. Embedded currency information wins:

Money.parse('10', 'USD')        #=> [USD 10.00]
Money.parse('10 EUR', 'USD')    #=> [EUR 10.00]

Arithmetic and division — Money can be added or subtracted only with the same currency. Multiplication and scalar division return Money; dividing by same-currency Money returns a numeric ratio. Non-zero cross-currency operations and reverse numeric division raise TypeError.

price = Money.from(10, 'USD')
price + Money.from(2, 'USD')   #=> [USD 12.00]
price * 1.5                    #=> [USD 15.00]
price / 2                      #=> [USD 5.00]
price / Money.from(5, 'USD')   #=> (2/1)

Clamping — Bounds must be Money objects in the same currency or nil. An inclusive Range can be passed as the only argument:

price.clamp(Money.from(0, 'USD'), Money.from(8, 'USD')) #=> [USD 8.00]
price.clamp(Money.from(8, 'USD')..Money.from(12, 'USD')) #=> [USD 10.00]

Allocation — split(n) requires a positive integer. allocate(ratios) requires a non-empty list whose total is non-zero. Both preserve the original amount after subunit rounding; any leftover smallest units go to the first slots. Ratios may be negative and produce signed allocations, but use them only when that distribution is intentional:

Money.from(10, 'USD').split(3)       #=> [[USD 3.34], [USD 3.33], [USD 3.33]]
Money.from(10, 'USD').allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
Money.from(10, 'USD').allocate([-1, 2])   #=> [[USD -10.00], [USD 20.00]]

Conversions and serialization — to_r is exact, to_d returns a BigDecimal, and to_f can lose precision. to_hash and from_hash provide a string-safe round trip. JSON and Rails as_json integration belong to the money_attribute companion gem, not the core Minting gem:

money = Money.from(9.99, 'USD')
money.to_r                         #=> (999/100)
money.to_f                         #=> 9.99
Money.from_hash(money.to_hash)     #=> [USD 9.99]

Money ranges — Money ranges use succ and a Money step. Ruby 4.0 supports non-numeric steps natively; Minting patches Range#step for Money only on older supported Rubies.

Zero equality — Any zero amount is considered equal across currencies and to numeric zero (Money.from(0, 'USD') == Money.from(0, 'EUR') is intentionally true). Non-zero amounts must match currency and value.

Registered currencies — Money::Currency.register(code:, subunit:, symbol:, priority:, country:, name:, disambiguate_symbol:) adds custom currencies. country, name, and disambiguate_symbol are optional metadata fields; disambiguate_symbol is also used by %<dsymbol>s and symbol parsing. Only registered codes and symbols are recognized by the parser or searches. You don't need to register a currency to use it with most features.

Built-in currencies — 150+ ISO-4217 world currencies ship in lib/minting/data/world-currencies.yaml and are preloaded at gem initialization.

Top-level aliases

By default, require "minting" exposes Mint::Money as the top-level Money constant, so you can write Money.from(10, "USD") directly:

require "minting"

price = Money.from(10, "USD")   # equivalent to Mint::Money.from
tax   = Money.from(2.50, "USD")

Currency is not auto-bound, because application domain models are commonly named Currency (e.g. a Rails model). To opt in to the top-level Currency constant:

require "minting"
require "minting/aliases"  # opt-in top-level Currency

cur = Currency.new(code: "EUR", symbol: "€", subunit: 2, priority: 0)

For Rails applications, enable it in an initializer:

# config/initializers/minting.rb
require "minting/aliases"

If another Money is already defined when require "minting" runs (e.g. the money gem was loaded first), Minting warns and skips the auto-bind — use Mint::Money in that case. The same applies to Currency via minting/aliases.

Good fit: Application code, especially Rails apps. Not recommended: Reusable gems/libraries — stick to Mint::Money to avoid conflicts.

Rails integration

Minting itself has no Rails dependency. For ActiveRecord type casting, validators, and form helpers, pair it with the companion gem:

  • MoneyAttribute — a money_attribute macro for models, with ActiveRecord::Type integration and composed_of-based support for multi-column (amount + currency) attributes.

License

MIT