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.1 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 in 2.1

Breaking Changes

  • Rounding mode symbols renamed to match Rational#round half: parameter: :half_up:up, :half_down:down, :half_even:even

What's New in 2.0

Breaking Changes

  • 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
  • minting/mint/aliases abbreviated to minting/aliases
  • 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

New Features

  • Crypto currency support: Opt-in YAML-backed definitions for ~25 popular coins (BTC, ETH, SOL, ...). Use Currency.register_crypto('BTC', 'ETH') to register, or 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.

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 in 2.1
  • What's New in 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 youKernel.format-style templates, named presets (:accounting, :european), per-sign formats (parentheses for negatives), 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

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(0, 100)          #=> [USD 50.00]  (returns self, no new object)
price.clamp(0, 25)           #=> [USD 25.00]  (clamped to max)
price.clamp(min_price, 100)  #=> [USD 75.00]  (clamped to min, Money or Numeric bounds both work)

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"

# 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"

# Per-sign Hash format (e.g. accounting parentheses for losses)
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.
  • 1,234 means 1234, not 1.234, and 1,23 means 1.23, not 123.
  • 1,234.00 is unambiguous (thousands + decimal).
  • 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 (150+ 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    #=> 154

# 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 resolutionCurrency.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:

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

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

Currency.crypto_currencies lists all available definitions without registering:

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] || {} }

Mint.money(1234.56, 'USD').format(locale: :en)  #=> "$1,234.56"
Mint.money(9.99, 'BRL').format(locale: 'pt')    #=> "R$9,99"
Mint.money(9.99, 'EUR').format(locale: :de)     #=> "9,99 EUR"
Mint.money(9.99, 'EUR').format(locale: 'fr')    #=> "9,99 €"
Mint.money(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: '.' } }
Mint.money(9.99, 'BRL').format  #=> "R$9,99"

API notes

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

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, change, 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.

Divisionmoney / 5 returns new Money; money / other_money returns a numeric ratio, not money.

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 currenciesCurrency.register(code:, subunit:, symbol:, priority:) adds custom currencies. 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.

Optional top-level Money (opt-out) and Currency (opt-in)

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