Class: Philiprehberger::Money

Inherits:
Object
  • Object
show all
Includes:
Comparable, Allocation, Arithmetic, Formatting
Defined in:
lib/philiprehberger/money.rb,
lib/philiprehberger/money/parsing.rb,
lib/philiprehberger/money/version.rb,
lib/philiprehberger/money/currency.rb,
lib/philiprehberger/money/allocation.rb,
lib/philiprehberger/money/arithmetic.rb,
lib/philiprehberger/money/formatting.rb,
lib/philiprehberger/money/exchange_rate.rb

Overview

Immutable money value object with integer subunit storage

Defined Under Namespace

Modules: Allocation, Arithmetic, Formatting, Parsing Classes: Currency, CurrencyMismatch, Error, ExchangeRate, InvalidCurrency, ParseError

Constant Summary collapse

ROUNDING_MODES =
{
  half_even: BigDecimal::ROUND_HALF_EVEN,
  half_up: BigDecimal::ROUND_HALF_UP,
  ceil: BigDecimal::ROUND_CEILING,
  floor: BigDecimal::ROUND_FLOOR
}.freeze
VERSION =
'0.5.0'

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Allocation

#allocate

Methods included from Formatting

#format, #to_s

Methods included from Arithmetic

#*, #+, #-, #-@, #/, #abs

Constructor Details

#initialize(cents, currency_code, rounding: :half_even) ⇒ Money

Create a Money object from subunit cents

Parameters:

  • cents (Integer)

    amount in subunits (e.g. cents for USD)

  • currency_code (Symbol, String)

    ISO 4217 currency code

  • rounding (Symbol) (defaults to: :half_even)

    rounding mode (:half_even, :half_up, :ceil, :floor)



38
39
40
41
42
43
# File 'lib/philiprehberger/money.rb', line 38

def initialize(cents, currency_code, rounding: :half_even)
  @cents = Integer(cents)
  @currency = Currency.find(currency_code)
  @rounding_mode = rounding
  freeze
end

Instance Attribute Details

#centsObject (readonly)

Returns the value of attribute cents.



30
31
32
# File 'lib/philiprehberger/money.rb', line 30

def cents
  @cents
end

#currencyObject (readonly)

Returns the value of attribute currency.



30
31
32
# File 'lib/philiprehberger/money.rb', line 30

def currency
  @currency
end

#rounding_modeObject (readonly)

Returns the value of attribute rounding_mode.



30
31
32
# File 'lib/philiprehberger/money.rb', line 30

def rounding_mode
  @rounding_mode
end

Class Method Details

.from_amount(amount, currency_code, rounding: :half_even) ⇒ Money

Create a Money object from a decimal amount

Parameters:

  • amount (Numeric, String)

    decimal amount (e.g. 19.99)

  • currency_code (Symbol, String)

    ISO 4217 currency code

  • rounding (Symbol) (defaults to: :half_even)

    rounding mode (:half_even, :half_up, :ceil, :floor)

Returns:



51
52
53
54
55
56
57
58
59
60
61
# File 'lib/philiprehberger/money.rb', line 51

def self.from_amount(amount, currency_code, rounding: :half_even)
  curr = Currency.find(currency_code)
  mode = ROUNDING_MODES.fetch(rounding) do
    raise ArgumentError, "Unknown rounding mode: #{rounding}. Valid modes: #{ROUNDING_MODES.keys.join(', ')}"
  end
  new(
    (BigDecimal(amount.to_s) * curr.subunit_to_unit).round(0, mode).to_i,
    currency_code,
    rounding: rounding
  )
end

.parse(input, currency: nil) ⇒ Money

Parse a formatted money string into a Money object

Parameters:

  • input (String)

    formatted money string

  • currency (Symbol, String, nil) (defaults to: nil)

    optional currency code

Returns:

Raises:



69
70
71
# File 'lib/philiprehberger/money.rb', line 69

def self.parse(input, currency: nil)
  Parsing.parse(input, currency: currency)
end

.sum(moneys, target_currency:) ⇒ Money

Sum a collection of Money objects, converting to a target currency

Parameters:

  • moneys (Array<Money>)

    collection of Money objects

  • target_currency (Symbol, String)

    currency code to sum into

Returns:

  • (Money)

    total in the target currency

Raises:

  • (Error)

    if the collection is empty



79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/philiprehberger/money.rb', line 79

def self.sum(moneys, target_currency:)
  raise Error, 'Collection must not be empty' if moneys.nil? || moneys.empty?

  moneys.reduce(Money.new(0, target_currency)) do |total, money|
    converted = if money.currency.code == target_currency.to_s.upcase.to_sym
                  money
                else
                  money.exchange_to(target_currency)
                end
    total + converted
  end
end

Instance Method Details

#<=>(other) ⇒ Integer?

Compare two Money objects of the same currency

Parameters:

Returns:

  • (Integer, nil)

    -1, 0, 1, or nil if currencies differ



110
111
112
113
114
# File 'lib/philiprehberger/money.rb', line 110

def <=>(other)
  return nil unless other.is_a?(Money) && other.currency.code == currency.code

  cents <=> other.cents
end

#add_percent(n) ⇒ Money

Return money plus n% (e.g. for tax-inclusive pricing)

Parameters:

  • n (Numeric)

    percentage to add

Returns:



167
168
169
# File 'lib/philiprehberger/money.rb', line 167

def add_percent(n)
  self + percent(n)
end

#amountBigDecimal

The decimal amount

Returns:

  • (BigDecimal)


95
96
97
# File 'lib/philiprehberger/money.rb', line 95

def amount
  BigDecimal(@cents.to_s) / @currency.subunit_to_unit
end

#clamp(min, max) ⇒ Money

Constrain the money value within a minimum and maximum bound

Parameters:

  • min (Money)

    lower bound (same currency)

  • max (Money)

    upper bound (same currency)

Returns:

  • (Money)

    self if within range, min if below, max if above

Raises:



218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/philiprehberger/money.rb', line 218

def clamp(min, max)
  unless currency.code == min.currency.code
    raise CurrencyMismatch, "Cannot clamp #{currency.code.upcase} with #{min.currency.code.upcase}"
  end
  unless currency.code == max.currency.code
    raise CurrencyMismatch, "Cannot clamp #{currency.code.upcase} with #{max.currency.code.upcase}"
  end
  raise ArgumentError, 'min must not be greater than max' if min > max

  return min if self < min
  return max if self > max

  self
end

#convert_to(target_code, rate:) ⇒ Money

Convert to another currency using a given rate

Parameters:

  • target_code (Symbol, String)

    target currency code

  • rate (Numeric)

    exchange rate (e.g. 1.25)

Returns:

  • (Money)

    new Money in the target currency



136
137
138
139
140
# File 'lib/philiprehberger/money.rb', line 136

def convert_to(target_code, rate:)
  Currency.find(target_code)
  converted = (BigDecimal(@cents.to_s) * BigDecimal(rate.to_s)).round(0, BigDecimal::ROUND_HALF_EVEN).to_i
  self.class.new(converted, target_code)
end

#deconstruct_keys(keys) ⇒ Hash

Pattern matching support for Ruby 3.x case/in

Parameters:

  • keys (Array<Symbol>, nil)

    keys to destructure

Returns:

  • (Hash)


261
262
263
264
265
266
# File 'lib/philiprehberger/money.rb', line 261

def deconstruct_keys(keys)
  full = { cents: @cents, amount: amount.to_f, currency: @currency.code, formatted: to_s }
  return full if keys.nil?

  full.slice(*keys)
end

#eql?(other) ⇒ Boolean

Returns true if same cents and currency.

Parameters:

  • other (Object)

Returns:

  • (Boolean)

    true if same cents and currency



149
150
151
# File 'lib/philiprehberger/money.rb', line 149

def eql?(other)
  other.is_a?(Money) && cents == other.cents && currency.code == other.currency.code
end

#exchange_to(target_code) ⇒ Money

Convert to another currency using the ExchangeRate store

Parameters:

  • target_code (Symbol, String)

    target currency code

Returns:

  • (Money)

    new Money in the target currency



194
195
196
197
# File 'lib/philiprehberger/money.rb', line 194

def exchange_to(target_code)
  rate = ExchangeRate.store.get(currency.code, target_code)
  convert_to(target_code, rate: rate)
end

#hashInteger

Returns hash based on cents and currency code.

Returns:

  • (Integer)

    hash based on cents and currency code



143
144
145
# File 'lib/philiprehberger/money.rb', line 143

def hash
  [@cents, @currency.code].hash
end

#negative?Boolean

Returns true if the amount is negative.

Returns:

  • (Boolean)

    true if the amount is negative



127
128
129
# File 'lib/philiprehberger/money.rb', line 127

def negative?
  @cents.negative?
end

#percent(n) ⇒ Money

Return n% of this money amount

Parameters:

  • n (Numeric)

    percentage (e.g. 15 for 15%)

Returns:

  • (Money)

    n% of the amount



157
158
159
160
161
# File 'lib/philiprehberger/money.rb', line 157

def percent(n)
  result = (BigDecimal(cents.to_s) * BigDecimal(n.to_s) / BigDecimal('100'))
           .round(0, BigDecimal::ROUND_HALF_EVEN).to_i
  self.class.new(result, currency.code)
end

#positive?Boolean

Returns true if the amount is positive.

Returns:

  • (Boolean)

    true if the amount is positive



122
123
124
# File 'lib/philiprehberger/money.rb', line 122

def positive?
  @cents.positive?
end

#round_to_nearest(increment) ⇒ Money

Round to the nearest N subunits

Parameters:

  • increment (Integer)

    rounding increment (e.g. 5 for nearest 5 cents)

Returns:

  • (Money)

    rounded Money object

Raises:

  • (Error)

    if increment is not a positive integer



238
239
240
241
242
243
# File 'lib/philiprehberger/money.rb', line 238

def round_to_nearest(increment)
  raise Error, 'Increment must be a positive integer' unless increment.is_a?(Integer) && increment.positive?

  rounded_cents = ((cents.to_f / increment).round * increment).to_i
  self.class.new(rounded_cents, currency.code, rounding: rounding_mode)
end

#split(n) ⇒ Array<Money>

Split money equally among n parts

Parameters:

  • n (Integer)

    number of parts

Returns:

  • (Array<Money>)

    array of Money objects that sum to the original

Raises:

  • (ArgumentError)

    if n < 1



184
185
186
187
188
# File 'lib/philiprehberger/money.rb', line 184

def split(n)
  raise ArgumentError, 'Number of parts must be 1 or more' unless n.is_a?(Integer) && n >= 1

  allocate(Array.new(n, 1))
end

#subtract_percent(n) ⇒ Money

Return money minus n% (e.g. for discounts)

Parameters:

  • n (Numeric)

    percentage to subtract

Returns:



175
176
177
# File 'lib/philiprehberger/money.rb', line 175

def subtract_percent(n)
  self - percent(n)
end

#tax_breakdown(rate) ⇒ Hash

Calculate tax breakdown from the current amount as net

Parameters:

  • rate (Numeric)

    tax rate as a decimal (e.g. 0.2 for 20%)

Returns:

  • (Hash)

    with :net, :tax, and :gross Money objects

Raises:

  • (ArgumentError)


203
204
205
206
207
208
209
210
# File 'lib/philiprehberger/money.rb', line 203

def tax_breakdown(rate)
  raise ArgumentError, 'Tax rate must be non-negative' unless rate.is_a?(Numeric) && rate >= 0

  tax_cents = (BigDecimal(cents.to_s) * BigDecimal(rate.to_s))
              .round(0, BigDecimal::ROUND_HALF_EVEN).to_i
  tax = self.class.new(tax_cents, currency.code)
  { net: self, tax: tax, gross: self + tax }
end

#to_fFloat

The decimal amount as a float

Returns:

  • (Float)


102
103
104
# File 'lib/philiprehberger/money.rb', line 102

def to_f
  amount.to_f
end

#to_hHash

Hash representation for serialization

Returns:

  • (Hash)

    with :cents, :amount, :currency, :formatted keys



248
249
250
251
252
253
254
255
# File 'lib/philiprehberger/money.rb', line 248

def to_h
  {
    cents: @cents,
    amount: amount.to_f,
    currency: @currency.code.to_s.upcase,
    formatted: to_s
  }
end

#zero?Boolean

Returns true if the amount is zero.

Returns:

  • (Boolean)

    true if the amount is zero



117
118
119
# File 'lib/philiprehberger/money.rb', line 117

def zero?
  @cents.zero?
end