philiprehberger-money

Tests Gem Version Last updated

Immutable money value object with integer subunit storage and multi-currency formatting

Requirements

  • Ruby >= 3.1

Installation

Add to your Gemfile:

gem "philiprehberger-money"

Or install directly:

gem install philiprehberger-money

Usage

require "philiprehberger/money"

price = Philiprehberger::Money.new(1999, :usd)
price.amount  # => 19.99
price.to_s    # => "$19.99"

Arithmetic

require "philiprehberger/money"

a = Philiprehberger::Money.new(1000, :usd)
b = Philiprehberger::Money.new(500, :usd)

sum  = a + b       # => $15.00
diff = a - b       # => $5.00
mult = a * 2       # => $20.00
quot = a / 3       # => $3.33 (banker's rounding)
neg  = -a          # => -$10.00
abs  = neg.abs     # => $10.00

Formatting

require "philiprehberger/money"

usd = Philiprehberger::Money.new(1999, :usd)
usd.format                          # => "$19.99"
usd.format(code: true)              # => "$19.99 USD"
usd.format(symbol: false)           # => "19.99"

eur = Philiprehberger::Money.new(1999, :eur)
eur.format                          # => "€19,99"

jpy = Philiprehberger::Money.new(2000, :jpy)
jpy.format                          # => "¥2,000"

Parsing

require "philiprehberger/money"

Philiprehberger::Money.parse("$1,234.56")       # => Money(123456, :usd)
Philiprehberger::Money.parse("1.234,56 EUR")     # => Money(123456, :eur)
Philiprehberger::Money.parse("¥2000", currency: :jpy)  # => Money(2000, :jpy)
Philiprehberger::Money.parse("-$19.99")          # => Money(-1999, :usd)

Percentage Operations

require "philiprehberger/money"

price = Philiprehberger::Money.new(10000, :usd)  # $100.00

price.percent(15)            # => $15.00 (15% tip)
price.add_percent(20)        # => $120.00 (with 20% tax)
price.subtract_percent(25)   # => $75.00 (25% discount)

Tax Breakdown

require "philiprehberger/money"

net = Philiprehberger::Money.new(10000, :usd)  # $100.00
result = net.tax_breakdown(0.2)

result[:net].to_s    # => "$100.00"
result[:tax].to_s    # => "$20.00"
result[:gross].to_s  # => "$120.00"

Clamping

require "philiprehberger/money"

min   = Philiprehberger::Money.new(500, :usd)   # $5.00
max   = Philiprehberger::Money.new(2000, :usd)  # $20.00

Philiprehberger::Money.new(1000, :usd).clamp(min, max).to_s  # => "$10.00" (within range)
Philiprehberger::Money.new(100, :usd).clamp(min, max).to_s   # => "$5.00"  (clamped to min)
Philiprehberger::Money.new(5000, :usd).clamp(min, max).to_s  # => "$20.00" (clamped to max)

Allocation

require "philiprehberger/money"

total = Philiprehberger::Money.new(1000, :usd)
shares = total.allocate([0.5, 0.3, 0.2])
shares.map(&:cents)  # => [500, 300, 200]

# Remainder cents are distributed fairly
odd = Philiprehberger::Money.new(100, :usd)
parts = odd.allocate([1, 1, 1])
parts.map(&:cents)   # => [34, 33, 33]
parts.sum(&:cents)   # => 100

Split

require "philiprehberger/money"

total = Philiprehberger::Money.new(1000, :usd)
parts = total.split(3)
parts.map(&:cents)   # => [334, 333, 333]
parts.sum(&:cents)   # => 1000

Rounding Modes

require "philiprehberger/money"

# Default: banker's rounding (half to even)
Philiprehberger::Money.from_amount(0.025, :usd)                        # => 2 cents

# Standard rounding (half up)
Philiprehberger::Money.from_amount(0.025, :usd, rounding: :half_up)    # => 3 cents

# Always round up
Philiprehberger::Money.from_amount(0.021, :usd, rounding: :ceil)       # => 3 cents

# Always round down
Philiprehberger::Money.from_amount(0.029, :usd, rounding: :floor)      # => 2 cents

Custom Currencies

require "philiprehberger/money"

Philiprehberger::Money::Currency.register(
  code: "XAU",
  name: "Gold Troy Ounce",
  symbol: "Au",
  subunit_to_unit: 100,
  symbol_first: true
)

gold = Philiprehberger::Money.from_amount(1950.50, :xau)
gold.format  # => "Au1,950.50"

Currency Conversion

require "philiprehberger/money"

usd = Philiprehberger::Money.new(1000, :usd)
eur = usd.convert_to(:eur, rate: 0.85)
eur.cents           # => 850
eur.currency.code   # => :eur

Exchange Rate Store

require "philiprehberger/money"

# Set up exchange rates
Philiprehberger::Money::ExchangeRate.store.set(:USD, :EUR, 0.85)
Philiprehberger::Money::ExchangeRate.store.set(:USD, :GBP, 0.73)

# Convert using the store (no need to pass a rate)
usd = Philiprehberger::Money.new(1000, :usd)
eur = usd.exchange_to(:EUR)
eur.cents  # => 850

# Inverse rates are resolved automatically
eur_amount = Philiprehberger::Money.new(850, :eur)
back_to_usd = eur_amount.exchange_to(:USD)

# Sum mixed-currency amounts into a target currency
moneys = [
  Philiprehberger::Money.new(1000, :usd),
  Philiprehberger::Money.new(850, :eur)
]
total = Philiprehberger::Money.sum(moneys, target_currency: :USD)
total.cents  # => 2000

# Clear all rates
Philiprehberger::Money::ExchangeRate.store.clear

Rounding to Nearest Increment

require "philiprehberger/money"

price = Philiprehberger::Money.new(123, :usd)  # $1.23

price.round_to_nearest(5).cents   # => 125 ($1.25)
price.round_to_nearest(10).cents  # => 120 ($1.20)
price.round_to_nearest(25).cents  # => 125 ($1.25)

Serialization

price = Philiprehberger::Money.new(1999, :usd)
price.to_h
# => { cents: 1999, amount: 19.99, currency: "USD", formatted: "$19.99" }

Pattern Matching

case Philiprehberger::Money.new(1999, :usd)
in { currency: :usd, amount: Float => amt }
  puts "USD amount: #{amt}"
end

Comparison

require "philiprehberger/money"

a = Philiprehberger::Money.new(1000, :usd)
b = Philiprehberger::Money.new(2000, :usd)

a < b   # => true
a == b  # => false
a.zero? # => false

API

Money class methods

Method Description
.new(cents, currency_code) Create from integer subunits and currency code
.from_amount(amount, currency_code, rounding:) Create from decimal amount with configurable rounding
.parse(string, currency:) Parse a formatted money string into a Money object
.sum(moneys, target_currency:) Sum a collection of Money objects into a target currency

Money instance methods

Method Description
#cents Integer subunit amount
#currency Currency object with code, symbol, and formatting rules
#amount BigDecimal representation of the amount
#to_f Float representation of the amount
#rounding_mode The rounding mode used for arithmetic
#+(other) Add two same-currency Money objects
#-(other) Subtract two same-currency Money objects
#*(numeric) Multiply by a number (uses stored rounding mode)
#/(numeric) Divide by a number (uses stored rounding mode)
#-@ Negate the amount
#abs Absolute value
#percent(n) Return n% of the amount
#add_percent(n) Return money + n%
#subtract_percent(n) Return money - n%
#tax_breakdown(rate) Return hash with net, tax, and gross Money objects
#clamp(min, max) Constrain value within same-currency bounds
#allocate(ratios) Split by ratios using largest remainder method
#split(n) Split equally among n parts
#format(symbol:, code:, thousands:) Format as string with options
#to_s Format with default options
#convert_to(target_code, rate:) Convert to another currency
#exchange_to(target_code) Convert using the ExchangeRate store
#round_to_nearest(increment) Round to nearest N subunits
#zero? True if amount is zero
#positive? True if amount is positive
#negative? True if amount is negative
#<=>(other) Compare same-currency amounts
#to_h Hash with cents, amount, currency, formatted
#deconstruct_keys(keys) Pattern matching support for case/in
#eql?(other) Value equality check
#hash Hash for use as hash key

ExchangeRate methods

Method Description
.store Returns the singleton exchange rate store
.reset! Resets the store (clears all rates)
#set(from, to, rate) Set a conversion rate between two currencies
#get(from, to) Get a conversion rate (resolves inverse automatically)
#clear Remove all stored rates
#rates_count Number of stored rates

Currency class methods

Method Description
.find(code) Look up a currency by code
.register(code:, name:, symbol:, ...) Register a custom currency

Development

bundle install
bundle exec rspec
bundle exec rubocop

Support

If you find this project useful:

Star the repo

🐛 Report issues

💡 Suggest features

❤️ Sponsor development

🌐 All Open Source Projects

💻 GitHub Profile

🔗 LinkedIn Profile

License

MIT