philiprehberger-money
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: