Manage money in Shopify with a class that won't lose pennies during division
money_column
expects a DECIMAL(21,3)
database field.
Money
class which encapsulates all information about a certain amount of money, such as its value and its currency.Money::Currency
class which encapsulates all information about a monetary unit.gem 'shopify-money'
require 'money'
# 10.00 USD
money = Money.new(10.00, "USD")
money.subunits #=> 1000
money.currency #=> Money::Currency.new("USD")
# Comparisons
Money.new(1000, "USD") == Money.new(1000, "USD") #=> true
Money.new(1000, "USD") == Money.new(100, "USD") #=> false
Money.new(1000, "USD") == Money.new(1000, "EUR") #=> false
Money.new(1000, "USD") != Money.new(1000, "EUR") #=> true
# Arithmetic
Money.new(1000, "USD") + Money.new(500, "USD") == Money.new(1500, "USD")
Money.new(1000, "USD") - Money.new(200, "USD") == Money.new(800, "USD")
Money.new(1000, "USD") * 5 == Money.new(5000, "USD")
m = Money.new(1000, "USD")
# Splitting money evenly
m.split(2) == [Money.new(500, "USD"), Money.new(500, "USD")]
m.split(3).map(&:value) == [333.34, 333.33, 333.33]
m.calculate_splits(2) == { Money.new(500, "USD") => 2 }
m.calculate_splits(3) == { Money.new(333.34, "USD") => 1, Money.new(333.33, "USD") =>2 }
# Allocating money proportionally
m.allocate([0.50, 0.25, 0.25]).map(&:value) == [500, 250, 250]
m.allocate([Rational(2, 3), Rational(1, 3)]).map(&:value) == [666.67, 333.33]
## Allocating up to a cutoff
m.allocate_max_amounts([500, 300, 200]).map(&:value) == [500, 300, 200]
m.allocate_max_amounts([500, 300, 300]).map(&:value) == [454.55, 272.73, 272.72]
## Selectable rounding strategies during division
# Assigns leftover subunits left to right
m = Money::Allocator.new(Money.new(10.55, "USD"))
monies = m.allocate([0.25, 0.5, 0.25], :roundrobin)
#monies[0] == 2.64 <-- gets 1 penny
#monies[1] == 5.28 <-- gets 1 penny
#monies[2] == 2.63 <-- gets no penny
# Assigns leftover subunits right to left
m = Money::Allocator.new(Money.new(10.55, "USD"))
monies = m.allocate([0.25, 0.5, 0.25], :roundrobin_reverse)
#monies[0] == 2.63 <-- gets no penny
#monies[1] == 5.28 <-- gets 1 penny
#monies[2] == 2.64 <-- gets 1 penny
# Assigns leftover subunits to the nearest whole subunit
m = Money::Allocator.new(Money.new(10.55, "USD"))
monies = m.allocate([0.25, 0.5, 0.25], :nearest)
#monies[0] == 2.64 <-- gets 1 penny
#monies[1] == 5.27 <-- gets no penny
#monies[2] == 2.64 <-- gets 1 penny
# $2.6375 is closer to the next whole penny than $5.275
# Clamp
Money.new(50, "USD").clamp(1, 100) == Money.new(50, "USD")
# Unit to subunit conversions
Money.from_subunits(500, "USD") == Money.new(5, "USD") # 5 USD
Money.from_subunits(5, "JPY") == Money.new(5, "JPY") # 5 JPY
Money.from_subunits(5000, "TND") == Money.new(5, "TND") # 5 TND
Currencies are consistently represented as instances of Money::Currency
.
The most part of Money
APIs allows you to supply either a String
or a
Money::Currency
.
Money.new(1000, "USD") == Money.new(1000, Money::Currency.new("USD"))
Money.new(1000, "EUR").currency == Money::Currency.new("EUR")
A Money::Currency
instance holds all the information about the currency,
including the currency symbol, name and much more.
currency = Money.new(1000, "USD").currency
currency.iso_code #=> "USD"
currency.name #=> "United States Dollar"
currency.to_s #=> 'USD'
currency.symbol #=> '$'
currency.disambiguate_symbol #=> 'US$'
By default Money
defaults to Money::NullCurrency as its currency. This is a
global variable that can be changed using:
Money.configure do |config|
config.default_currency = Money::Currency.new("USD")
end
In web apps you might want to set the default currency on a per request basis.
In Rails you can do this with an around action, for example:
class ApplicationController < ActionController::Base
around_action :set_currency
private
def set_currency
Money.with_currency(current_shop.currency) { yield }
end
end
The exponent of a money value is the number of digits after the decimal
separator (which separates the major unit from the minor unit).
Money::Currency.new("USD").minor_units # => 2
Money::Currency.new("JPY").minor_units # => 0
Money::Currency.new("MGA").minor_units # => 1
Money.new(money * exchange_rate, "JPY")
will raise an exception. The valid alternatives are:
Money.new(money.value * exchange_rate, "JPY")
# Or
money.convert_currency(exchange_rate, "JPY")
Since money internally uses BigDecimal it’s logical to use a decimal
column
for your database. The money_column
method can generate methods for use with
ActiveRecord:
create_table :orders do |t|
t.decimal :sub_total, precision: 21, scale: 3
t.decimal :tax, precision: 21, scale: 3
t.string :currency, limit: 3
end
class Order < ApplicationRecord
money_column :sub_total, :tax
end
option | type | description |
---|---|---|
currency_column | method | column from which to read/write the currency |
currency | string | hardcoded currency value |
currency_read_only | boolean | when true, currency_column won’t write the currency back into the db. Must be set to true if currency_column is an attr_reader or delegate. Default: false |
coerce_null | boolean | when true, a nil value will be returned as Money.zero. Default: false |
You can use multiple money_column
calls to achieve the desired effects with
currency on the model or attribute level.
There are no validations generated. You can add these for the specified money
and currency attributes as you normally would for any other.
A RuboCop rule to enforce the presence of a currency using static analysis is available.
Add to your .rubocop.yml
require:
- money
Money/MissingCurrency:
Enabled: true
# ReplacementCurrency: CAD
Money/ZeroMoney:
Enabled: true
# ReplacementCurrency: CAD
To release a new version of the gem, follow these steps:
lib/money/version
.main
branch with a tag matching the new version, prefixed with v
(e.g. v1.2.3
).Copyright © 2011 Shopify. See LICENSE.txt for
further details.