# encoding: utf-8
require "money/bank/variable_exchange"
require "money/bank/single_currency"
require "money/money/arithmetic"
require "money/money/constructors"
require "money/money/formatter"
require "money/money/allocation"
require "money/money/locale_backend"
# "Money is any object or record that is generally accepted as payment for
# goods and services and repayment of debts in a given socio-economic context
# or country." -Wikipedia
#
# An instance of Money represents an amount of a specific currency.
#
# Money is a value object and should be treated as immutable.
#
# @see http://en.wikipedia.org/wiki/Money
class Money
include Comparable
include Money::Arithmetic
extend Constructors
# Raised when smallest denomination of a currency is not defined
class UndefinedSmallestDenomination < StandardError; end
# Convenience method for fractional part of the amount. Synonym of #fractional
#
# @return [Integer] when infinite_precision is false
# @return [BigDecimal] when infinite_precision is true
#
# @see infinite_precision
def cents
fractional
end
# The value of the monetary amount represented in the fractional or subunit
# of the currency.
#
# For example, in the US dollar currency the fractional unit is cents, and
# there are 100 cents in one US dollar. So given the Money representation of
# one US dollar, the fractional interpretation is 100.
#
# Another example is that of the Kuwaiti dinar. In this case the fractional
# unit is the fils and there 1000 fils to one Kuwaiti dinar. So given the
# Money representation of one Kuwaiti dinar, the fractional interpretation is
# 1000.
#
# @return [Integer] when infinite_precision is false
# @return [BigDecimal] when infinite_precision is true
#
# @see infinite_precision
def fractional
# Ensure we have a BigDecimal. If the Money object is created
# from YAML, @fractional can end up being set to a Float.
fractional = as_d(@fractional)
return_value(fractional)
end
# Round a given amount of money to the nearest possible amount in cash value. For
# example, in Swiss franc (CHF), the smallest possible amount of cash value is
# CHF 0.05. Therefore, this method rounds CHF 0.07 to CHF 0.05, and CHF 0.08 to
# CHF 0.10.
#
# @return [Integer] when infinite_precision is false
# @return [BigDecimal] when infinite_precision is true
#
# @see infinite_precision
def round_to_nearest_cash_value
unless self.currency.smallest_denomination
raise UndefinedSmallestDenomination, 'Smallest denomination of this currency is not defined'
end
fractional = as_d(@fractional)
smallest_denomination = as_d(self.currency.smallest_denomination)
rounded_value = (fractional / smallest_denomination).round(0, self.class.rounding_mode) * smallest_denomination
return_value(rounded_value)
end
# @!attribute [r] currency
# @return [Currency] The money's currency.
# @!attribute [r] bank
# @return [Money::Bank::Base] The +Money::Bank+-based object which currency
# exchanges are performed with.
attr_reader :currency, :bank
# Class Methods
class << self
# @!attribute [rw] default_bank
# Used to set a default bank for currency exchange.
#
# Each Money object is associated with a bank
# object, which is responsible for currency exchange. This property
# allows you to specify the default bank object. The default value for
# this property is an instance of +Bank::VariableExchange.+ It allows
# one to specify custom exchange rates.
#
# @return [Money::Bank::Base]
#
# @!attribute default_formatting_rules
# Used to define a default hash of rules for every time
# +Money#format+ is called. Rules provided on method call will be
# merged with the default ones. To overwrite a rule, just provide the
# intended value while calling +format+.
#
# @see Money::Formatter#initialize Money::Formatter for more details
#
# @example
# Money.default_formatting_rules = { display_free: true }
# Money.new(0, "USD").format # => "free"
# Money.new(0, "USD").format(display_free: false) # => "$0.00"
#
# @return [Hash]
#
# @!attribute [rw] use_i18n
# Used to disable i18n even if it's used by other components of your app.
#
# @return [Boolean]
#
# @!attribute [rw] default_infinite_precision
# @return [Boolean] Use this to enable infinite precision cents as the
# global default
#
# @!attribute [rw] conversion_precision
# Used to specify precision for converting Rational to BigDecimal
#
# @return [Integer]
attr_accessor :default_formatting_rules, :default_infinite_precision, :conversion_precision
attr_reader :use_i18n, :locale_backend
attr_writer :default_bank
def infinite_precision
warn '[DEPRECATION] `Money.infinite_precision` is deprecated - use `Money.default_infinite_precision` instead'
default_infinite_precision
end
def infinite_precision=(value)
warn '[DEPRECATION] `Money.infinite_precision=` is deprecated - use `Money.default_infinite_precision= ` instead'
self.default_infinite_precision = value
end
end
# @!attribute default_currency
# @return [Money::Currency] The default currency, which is used when
# +Money.new+ is called without an explicit currency argument. The
# default value is Currency.new("USD"). The value must be a valid
# +Money::Currency+ instance.
def self.default_currency
if @using_deprecated_default_currency
warn '[WARNING] The default currency will change from `USD` to `nil` in the next major release. Make ' \
'sure to set it explicitly using `Money.default_currency=` to avoid potential issues'
@using_deprecated_default_currency = false
end
if @default_currency.respond_to?(:call)
Money::Currency.new(@default_currency.call)
else
Money::Currency.new(@default_currency)
end
end
def self.default_currency=(currency)
@using_deprecated_default_currency = false
@default_currency = currency
end
def self.default_bank
if @default_bank.respond_to?(:call)
@default_bank.call
else
@default_bank
end
end
def self.locale_backend=(value)
@locale_backend = value ? LocaleBackend.find(value) : nil
end
# @attr_writer rounding_mode Use this to specify the rounding mode
def self.rounding_mode=(new_rounding_mode)
@using_deprecated_default_rounding_mode = false
@rounding_mode = new_rounding_mode
end
def self.use_i18n=(value)
if value
warn '[DEPRECATION] `use_i18n` is deprecated - use `Money.locale_backend = :i18n` instead for locale based formatting'
else
warn '[DEPRECATION] `use_i18n` is deprecated - use `Money.locale_backend = :currency` instead for currency based formatting'
end
@use_i18n = value
end
def self.setup_defaults
# Set the default bank for creating new +Money+ objects.
self.default_bank = Bank::VariableExchange.instance
# Set the default currency for creating new +Money+ object.
self.default_currency = Currency.new("USD")
@using_deprecated_default_currency = true
# Default to using i18n
@use_i18n = true
# Default to using legacy locale backend
self.locale_backend = :legacy
# Default to not using infinite precision cents
self.default_infinite_precision = false
# Default to bankers rounding
self.rounding_mode = BigDecimal::ROUND_HALF_EVEN
@using_deprecated_default_rounding_mode = true
# Default the conversion of Rationals precision to 16
self.conversion_precision = 16
end
def self.inherited(base)
base.setup_defaults
end
setup_defaults
# Use this to return the rounding mode.
#
# @param [BigDecimal::ROUND_MODE] mode
#
# @return [BigDecimal::ROUND_MODE] rounding mode
def self.rounding_mode(mode = nil)
if mode
warn "[DEPRECATION] calling `rounding_mode` with a block is deprecated. Please use `.with_rounding_mode` instead."
return with_rounding_mode(mode) { yield }
end
return Thread.current[:money_rounding_mode] if Thread.current[:money_rounding_mode]
if @using_deprecated_default_rounding_mode
warn '[WARNING] The default rounding mode will change from `ROUND_HALF_EVEN` to `ROUND_HALF_UP` in the ' \
'next major release. Set it explicitly using `Money.rounding_mode=` to avoid potential problems.'
@using_deprecated_default_rounding_mode = false
end
@rounding_mode
end
# Temporarily changes the rounding mode in a given block.
#
# @param [BigDecimal::ROUND_MODE] mode
#
# @yield The block within which rounding mode will be changed. Its return
# value will also be the return value of the whole method.
#
# @return [Object] block results
#
# @example
# fee = Money.with_rounding_mode(BigDecimal::ROUND_HALF_UP) do
# Money.new(1200) * BigDecimal('0.029')
# end
def self.with_rounding_mode(mode)
Thread.current[:money_rounding_mode] = mode
yield
ensure
Thread.current[:money_rounding_mode] = nil
end
# Adds a new exchange rate to the default bank and return the rate.
#
# @param [Currency, String, Symbol] from_currency Currency to exchange from.
# @param [Currency, String, Symbol] to_currency Currency to exchange to.
# @param [Numeric] rate Rate to exchange with.
#
# @return [Numeric]
#
# @example
# Money.add_rate("USD", "CAD", 1.25) #=> 1.25
def self.add_rate(from_currency, to_currency, rate)
Money.default_bank.add_rate(from_currency, to_currency, rate)
end
# Sets the default bank to be a SingleCurrency bank that raises on
# currency exchange. Useful when apps operate in a single currency at a time.
def self.disallow_currency_conversion!
self.default_bank = Bank::SingleCurrency.instance
end
# Creates a new Money object of value given in the +unit+ of the given
# +currency+.
#
# @param [Numeric] amount The numerical value of the money.
# @param [Currency, String, Symbol] currency The currency format.
# @param [Hash] options Optional settings for the new Money instance
# @option [Money::Bank::*] :bank The exchange bank to use.
#
# @example
# Money.from_amount(23.45, "USD") # => #<Money fractional:2345 currency:USD>
# Money.from_amount(23.45, "JPY") # => #<Money fractional:23 currency:JPY>
#
# @return [Money]
#
# @see #initialize
def self.from_amount(amount, currency = default_currency, options = {})
raise ArgumentError, "'amount' must be numeric" unless Numeric === amount
currency = Currency.wrap(currency) || Money.default_currency
value = amount.to_d * currency.subunit_to_unit
new(value, currency, options)
end
class << self
alias_method :from_cents, :new
end
# Creates a new Money object of value given in the
# +fractional unit+ of the given +currency+.
#
# Alternatively you can use the convenience
# methods like {Money.ca_dollar} and {Money.us_dollar}.
#
# @param [Object] obj Either the fractional value of the money,
# a Money object, or a currency. (If passed a currency as the first
# argument, a Money will be created in that currency with fractional value
# = 0.
# @param [Currency, String, Symbol] currency The currency format.
# @param [Hash] options Optional settings for the new Money instance
# @option [Money::Bank::*] :bank The exchange bank to use.
#
# @return [Money]
#
# @example
# Money.new(100) #=> #<Money @fractional=100 @currency="USD">
# Money.new(100, "USD") #=> #<Money @fractional=100 @currency="USD">
# Money.new(100, "EUR") #=> #<Money @fractional=100 @currency="EUR">
#
def initialize( obj, currency = Money.default_currency, options = {})
# For backwards compatability, if options is not a Hash, treat it as a bank parameter
unless options.is_a?(Hash)
options = { bank: options }
end
@fractional = as_d(obj.respond_to?(:fractional) ? obj.fractional : obj)
@currency = obj.respond_to?(:currency) ? obj.currency : Currency.wrap(currency)
@currency ||= Money.default_currency
@bank = obj.respond_to?(:bank) ? obj.bank : options[:bank]
@bank ||= Money.default_bank
# BigDecimal can be Infinity and NaN, money of that amount does not make sense
raise ArgumentError, 'must be initialized with a finite value' unless @fractional.finite?
end
# Assuming using a currency using dollars:
# Returns the value of the money in dollars,
# instead of in the fractional unit cents.
#
# Synonym of #amount
#
# @return [BigDecimal]
#
# @example
# Money.new(1_00, "USD").dollars # => BigDecimal("1.00")
#
# @see #amount
# @see #to_d
# @see #cents
#
def dollars
amount
end
# Returns the numerical value of the money
#
# @return [BigDecimal]
#
# @example
# Money.new(1_00, "USD").amount # => BigDecimal("1.00")
#
# @see #to_d
# @see #fractional
#
def amount
to_d
end
# Return string representation of currency object
#
# @return [String]
#
# @example
# Money.new(100, :USD).currency_as_string #=> "USD"
def currency_as_string
warn "[DEPRECATION] `currency_as_string` is deprecated. Please use `.currency.to_s` instead."
currency.to_s
end
# Set currency object using a string
#
# @param [String] val The currency string.
#
# @return [Money::Currency]
#
# @example
# Money.new(100).currency_as_string("CAD") #=> #<Money::Currency id: cad>
def currency_as_string=(val)
warn "[DEPRECATION] `currency_as_string=` is deprecated - Money instances are immutable." \
" Please use `with_currency` instead."
@currency = Currency.wrap(val)
end
# Returns a Integer hash value based on the +fractional+ and +currency+ attributes
# in order to use functions like & (intersection), group_by, etc.
#
# @return [Integer]
#
# @example
# Money.new(100).hash #=> 908351
def hash
[fractional.hash, currency.hash].hash
end
# Uses +Currency#symbol+. If +nil+ is returned, defaults to "¤".
#
# @return [String]
#
# @example
# Money.new(100, "USD").symbol #=> "$"
def symbol
currency.symbol || "¤"
end
# Common inspect function
#
# @return [String]
def inspect
"#<#{self.class.name} fractional:#{fractional} currency:#{currency}>"
end
# Returns the amount of money as a string.
#
# @return [String]
#
# @example
# Money.ca_dollar(100).to_s #=> "1.00"
def to_s
format thousands_separator: '',
no_cents_if_whole: currency.decimal_places == 0,
symbol: false,
ignore_defaults: true
end
# Return the amount of money as a BigDecimal.
#
# @return [BigDecimal]
#
# @example
# Money.us_dollar(1_00).to_d #=> BigDecimal("1.00")
def to_d
as_d(fractional) / as_d(currency.subunit_to_unit)
end
# Return the amount of money as a Integer.
#
# @return [Integer]
#
# @example
# Money.us_dollar(1_00).to_i #=> 1
def to_i
to_d.to_i
end
# Return the amount of money as a float. Floating points cannot guarantee
# precision. Therefore, this function should only be used when you no longer
# need to represent currency or working with another system that requires
# floats.
#
# @return [Float]
#
# @example
# Money.us_dollar(100).to_f #=> 1.0
def to_f
to_d.to_f
end
# Returns a new Money instance in a given currency leaving the amount intact
# and not performing currency conversion.
#
# @param [Currency, String, Symbol] new_currency Currency of the new object.
#
# @return [self]
def with_currency(new_currency)
new_currency = Currency.wrap(new_currency)
if !new_currency || currency == new_currency
self
else
dup_with(currency: new_currency)
end
end
# Conversion to +self+.
#
# @return [self]
def to_money(given_currency = nil)
given_currency = Currency.wrap(given_currency)
if given_currency.nil? || self.currency == given_currency
self
else
exchange_to(given_currency)
end
end
# Receive the amount of this money object in another Currency.
#
# @param [Currency, String, Symbol] other_currency Currency to exchange to.
#
# @yield [n] Optional block to use when rounding after exchanging one currency
# for another.
# @yieldparam [Float] n The resulting float after exchanging one currency for
# another.
# @yieldreturn [Integer]
#
# @return [Money]
#
# @example
# Money.new(2000, "USD").exchange_to("EUR")
# Money.new(2000, "USD").exchange_to("EUR") {|x| x.round}
# Money.new(2000, "USD").exchange_to(Currency.new("EUR"))
def exchange_to(other_currency, &rounding_method)
other_currency = Currency.wrap(other_currency)
if self.currency == other_currency
self
else
@bank.exchange_with(self, other_currency, &rounding_method)
end
end
# Receive a money object with the same amount as the current Money object
# in United States dollar.
#
# @return [Money]
#
# @example
# n = Money.new(100, "CAD").as_us_dollar
# n.currency #=> #<Money::Currency id: usd>
def as_us_dollar
exchange_to("USD")
end
# Receive a money object with the same amount as the current Money object
# in Canadian dollar.
#
# @return [Money]
#
# @example
# n = Money.new(100, "USD").as_ca_dollar
# n.currency #=> #<Money::Currency id: cad>
def as_ca_dollar
exchange_to("CAD")
end
# Receive a money object with the same amount as the current Money object
# in euro.
#
# @return [Money]
#
# @example
# n = Money.new(100, "USD").as_euro
# n.currency #=> #<Money::Currency id: eur>
def as_euro
exchange_to("EUR")
end
# Splits a given amount in parts without losing pennies. The left-over pennies will be
# distributed round-robin amongst the parties. This means that parts listed first will likely
# receive more pennies than ones listed later.
#
# Pass [2, 1, 1] as input to give twice as much to part1 as part2 or
# part3 which results in 50% of the cash to party1, 25% to part2, and 25% to part3. Passing a
# number instead of an array will split the amount evenly (without losing pennies when rounding).
#
# @param [Array<Numeric>, Numeric] parts how amount should be distributed to parts
#
# @return [Array<Money>]
#
# @example
# Money.new(5, "USD").allocate([3, 7]) #=> [Money.new(2), Money.new(3)]
# Money.new(100, "USD").allocate([1, 1, 1]) #=> [Money.new(34), Money.new(33), Money.new(33)]
# Money.new(100, "USD").allocate(2) #=> [Money.new(50), Money.new(50)]
# Money.new(100, "USD").allocate(3) #=> [Money.new(34), Money.new(33), Money.new(33)]
#
def allocate(parts)
amounts = Money::Allocation.generate(fractional, parts, !Money.default_infinite_precision)
amounts.map { |amount| dup_with(fractional: amount) }
end
alias_method :split, :allocate
# Round the monetary amount to smallest unit of coinage.
#
# @note
# This method is only useful when operating with infinite_precision turned
# on. Without infinite_precision values are rounded to the smallest unit of
# coinage automatically.
#
# @return [Money]
#
# @example
# Money.new(10.1, 'USD').round #=> Money.new(10, 'USD')
#
# @see
# Money.default_infinite_precision
#
def round(rounding_mode = self.class.rounding_mode, rounding_precision = 0)
rounded_amount = as_d(@fractional).round(rounding_precision, rounding_mode)
dup_with(fractional: rounded_amount)
end
# Creates a formatted price string according to several rules.
#
# @param [Hash] rules See {Money::Formatter Money::Formatter} for the list of formatting options
#
# @return [String]
#
def format(*rules)
Money::Formatter.new(self, *rules).to_s
end
# Returns a thousands separator according to the locale
#
# @return [String]
#
def thousands_separator
(locale_backend && locale_backend.lookup(:thousands_separator, currency)) ||
Money::Formatter::DEFAULTS[:thousands_separator]
end
# Returns a decimal mark according to the locale
#
# @return [String]
#
def decimal_mark
(locale_backend && locale_backend.lookup(:decimal_mark, currency)) ||
Money::Formatter::DEFAULTS[:decimal_mark]
end
def dup_with(options = {})
self.class.new(
options[:fractional] || fractional,
options[:currency] || currency,
bank: options[:bank] || bank
)
end
private
def as_d(num)
if num.respond_to?(:to_d)
num.is_a?(Rational) ? num.to_d(self.class.conversion_precision) : num.to_d
else
BigDecimal(num.to_s.empty? ? 0 : num.to_s)
end
end
def return_value(value)
if self.class.default_infinite_precision
value
else
value.round(0, self.class.rounding_mode).to_i
end
end
def locale_backend
self.class.locale_backend
end
end