shopify-money 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.document +5 -0
- data/.gitignore +51 -0
- data/.rspec +1 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +20 -0
- data/README.md +156 -0
- data/Rakefile +42 -0
- data/bin/console +14 -0
- data/circle.yml +13 -0
- data/config/currency_historic.json +157 -0
- data/config/currency_iso.json +2642 -0
- data/config/currency_non_iso.json +82 -0
- data/dev.yml +9 -0
- data/lib/money.rb +10 -0
- data/lib/money/accounting_money_parser.rb +8 -0
- data/lib/money/core_extensions.rb +18 -0
- data/lib/money/currency.rb +59 -0
- data/lib/money/currency/loader.rb +26 -0
- data/lib/money/deprecations.rb +18 -0
- data/lib/money/helpers.rb +71 -0
- data/lib/money/money.rb +408 -0
- data/lib/money/money_parser.rb +152 -0
- data/lib/money/null_currency.rb +35 -0
- data/lib/money/version.rb +3 -0
- data/lib/money_accessor.rb +32 -0
- data/lib/money_column.rb +3 -0
- data/lib/money_column/active_record_hooks.rb +95 -0
- data/lib/money_column/active_record_type.rb +6 -0
- data/lib/money_column/railtie.rb +7 -0
- data/money.gemspec +27 -0
- data/spec/accounting_money_parser_spec.rb +204 -0
- data/spec/core_extensions_spec.rb +44 -0
- data/spec/currency/loader_spec.rb +21 -0
- data/spec/currency_spec.rb +113 -0
- data/spec/helpers_spec.rb +103 -0
- data/spec/money_accessor_spec.rb +86 -0
- data/spec/money_column_spec.rb +298 -0
- data/spec/money_parser_spec.rb +355 -0
- data/spec/money_spec.rb +853 -0
- data/spec/null_currency_spec.rb +46 -0
- data/spec/schema.rb +9 -0
- data/spec/spec_helper.rb +74 -0
- metadata +196 -0
@@ -0,0 +1,82 @@
|
|
1
|
+
{
|
2
|
+
"jep": {
|
3
|
+
"priority": 100,
|
4
|
+
"iso_code": "JEP",
|
5
|
+
"name": "Jersey Pound",
|
6
|
+
"symbol": "£",
|
7
|
+
"disambiguate_symbol": "JEP",
|
8
|
+
"alternate_symbols": [],
|
9
|
+
"subunit": "Penny",
|
10
|
+
"subunit_to_unit": 100,
|
11
|
+
"symbol_first": true,
|
12
|
+
"html_entity": "£",
|
13
|
+
"decimal_mark": ".",
|
14
|
+
"thousands_separator": ",",
|
15
|
+
"iso_numeric": "",
|
16
|
+
"smallest_denomination": 1
|
17
|
+
},
|
18
|
+
"ggp": {
|
19
|
+
"priority": 100,
|
20
|
+
"iso_code": "GGP",
|
21
|
+
"name": "Guernsey Pound",
|
22
|
+
"symbol": "£",
|
23
|
+
"disambiguate_symbol": "GGP",
|
24
|
+
"alternate_symbols": [],
|
25
|
+
"subunit": "Penny",
|
26
|
+
"subunit_to_unit": 100,
|
27
|
+
"symbol_first": true,
|
28
|
+
"html_entity": "£",
|
29
|
+
"decimal_mark": ".",
|
30
|
+
"thousands_separator": ",",
|
31
|
+
"iso_numeric": "",
|
32
|
+
"smallest_denomination": 1
|
33
|
+
},
|
34
|
+
"imp": {
|
35
|
+
"priority": 100,
|
36
|
+
"iso_code": "IMP",
|
37
|
+
"name": "Isle of Man Pound",
|
38
|
+
"symbol": "£",
|
39
|
+
"disambiguate_symbol": "IMP",
|
40
|
+
"alternate_symbols": ["M£"],
|
41
|
+
"subunit": "Penny",
|
42
|
+
"subunit_to_unit": 100,
|
43
|
+
"symbol_first": true,
|
44
|
+
"html_entity": "£",
|
45
|
+
"decimal_mark": ".",
|
46
|
+
"thousands_separator": ",",
|
47
|
+
"iso_numeric": "",
|
48
|
+
"smallest_denomination": 1
|
49
|
+
},
|
50
|
+
"xfu": {
|
51
|
+
"priority": 100,
|
52
|
+
"iso_code": "XFU",
|
53
|
+
"name": "UIC Franc",
|
54
|
+
"symbol": "",
|
55
|
+
"disambiguate_symbol": "XFU",
|
56
|
+
"alternate_symbols": [],
|
57
|
+
"subunit": "",
|
58
|
+
"subunit_to_unit": 100,
|
59
|
+
"symbol_first": true,
|
60
|
+
"html_entity": "",
|
61
|
+
"decimal_mark": ".",
|
62
|
+
"thousands_separator": ",",
|
63
|
+
"iso_numeric": "",
|
64
|
+
"smallest_denomination": ""
|
65
|
+
},
|
66
|
+
"gbx": {
|
67
|
+
"priority": 100,
|
68
|
+
"iso_code": "GBX",
|
69
|
+
"name": "British Penny",
|
70
|
+
"symbol": "",
|
71
|
+
"disambiguate_symbol": "GBX",
|
72
|
+
"alternate_symbols": [],
|
73
|
+
"subunit": "",
|
74
|
+
"subunit_to_unit": 1,
|
75
|
+
"symbol_first": true,
|
76
|
+
"html_entity": "",
|
77
|
+
"decimal_mark": ".",
|
78
|
+
"thousands_separator": ",",
|
79
|
+
"iso_numeric": "",
|
80
|
+
"smallest_denomination": 1
|
81
|
+
}
|
82
|
+
}
|
data/dev.yml
ADDED
data/lib/money.rb
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
require_relative 'money/money_parser'
|
2
|
+
require_relative 'money/helpers'
|
3
|
+
require_relative 'money/currency'
|
4
|
+
require_relative 'money/null_currency'
|
5
|
+
require_relative 'money/money'
|
6
|
+
require_relative 'money/deprecations'
|
7
|
+
require_relative 'money/accounting_money_parser'
|
8
|
+
require_relative 'money/core_extensions'
|
9
|
+
require_relative 'money_accessor'
|
10
|
+
require_relative 'money_column' if defined?(ActiveRecord)
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# Allows Writing of 100.to_money for +Numeric+ types
|
2
|
+
# 100.to_money => #<Money @cents=10000>
|
3
|
+
# 100.37.to_money => #<Money @cents=10037>
|
4
|
+
class Numeric
|
5
|
+
def to_money(currency = nil)
|
6
|
+
Money.new(self, currency)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
# Allows Writing of '100'.to_money for +String+ types
|
11
|
+
# Excess characters will be discarded
|
12
|
+
# '100'.to_money => #<Money @cents=10000>
|
13
|
+
# '100.37'.to_money => #<Money @cents=10037>
|
14
|
+
class String
|
15
|
+
def to_money(currency = nil)
|
16
|
+
empty? ? Money.empty : Money.parse(self, currency)
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require "money/currency/loader"
|
2
|
+
|
3
|
+
class Money
|
4
|
+
class Currency
|
5
|
+
@@mutex = Mutex.new
|
6
|
+
@@loaded_currencies = {}
|
7
|
+
|
8
|
+
class UnknownCurrency < ArgumentError; end
|
9
|
+
|
10
|
+
class << self
|
11
|
+
def new(currency_iso)
|
12
|
+
raise UnknownCurrency, "Currency can't be blank" if currency_iso.nil? || currency_iso.to_s.empty?
|
13
|
+
iso = currency_iso.to_s.downcase
|
14
|
+
@@loaded_currencies[iso] || @@mutex.synchronize { @@loaded_currencies[iso] = super(iso) }
|
15
|
+
end
|
16
|
+
alias_method :find!, :new
|
17
|
+
|
18
|
+
def find(currency_iso)
|
19
|
+
new(currency_iso)
|
20
|
+
rescue UnknownCurrency
|
21
|
+
nil
|
22
|
+
end
|
23
|
+
|
24
|
+
def currencies
|
25
|
+
@@currencies ||= Loader.load_currencies
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
attr_reader :iso_code, :iso_numeric, :name, :smallest_denomination, :subunit_symbol,
|
30
|
+
:subunit_to_unit, :minor_units, :symbol, :disambiguate_symbol, :decimal_mark
|
31
|
+
|
32
|
+
def initialize(currency_iso)
|
33
|
+
data = self.class.currencies[currency_iso]
|
34
|
+
raise UnknownCurrency, "Invalid iso4217 currency '#{currency_iso}'" unless data
|
35
|
+
@symbol = data['symbol']
|
36
|
+
@disambiguate_symbol = data['disambiguate_symbol'] || data['symbol']
|
37
|
+
@subunit_symbol = data['subunit_symbol']
|
38
|
+
@iso_code = data['iso_code']
|
39
|
+
@iso_numeric = data['iso_numeric']
|
40
|
+
@name = data['name']
|
41
|
+
@smallest_denomination = data['smallest_denomination']
|
42
|
+
@subunit_to_unit = data['subunit_to_unit']
|
43
|
+
@decimal_mark = data['decimal_mark']
|
44
|
+
@minor_units = subunit_to_unit == 0 ? 0 : Math.log(subunit_to_unit, 10).round.to_i
|
45
|
+
freeze
|
46
|
+
end
|
47
|
+
|
48
|
+
def eql?(other)
|
49
|
+
self.class == other.class && iso_code == other.iso_code
|
50
|
+
end
|
51
|
+
|
52
|
+
def compatible?(other)
|
53
|
+
other.is_a?(NullCurrency) || eql?(other)
|
54
|
+
end
|
55
|
+
|
56
|
+
alias_method :==, :eql?
|
57
|
+
alias_method :to_s, :iso_code
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
class Money
|
4
|
+
class Currency
|
5
|
+
module Loader
|
6
|
+
extend self
|
7
|
+
|
8
|
+
CURRENCY_DATA_PATH = File.expand_path("../../../../config", __FILE__)
|
9
|
+
|
10
|
+
def load_currencies
|
11
|
+
currencies = {}
|
12
|
+
currencies.merge! parse_currency_file("currency_historic.json")
|
13
|
+
currencies.merge! parse_currency_file("currency_non_iso.json")
|
14
|
+
currencies.merge! parse_currency_file("currency_iso.json")
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def parse_currency_file(filename)
|
20
|
+
json = File.read("#{CURRENCY_DATA_PATH}/#{filename}")
|
21
|
+
json.force_encoding(::Encoding::UTF_8) if defined?(::Encoding)
|
22
|
+
JSON.parse(json)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
Money.class_eval do
|
2
|
+
ACTIVE_SUPPORT_DEFINED = defined?(ActiveSupport)
|
3
|
+
|
4
|
+
def self.active_support_deprecator
|
5
|
+
@active_support_deprecator ||= ActiveSupport::Deprecation.new('1.0.0', 'Shopify/Money')
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.deprecate(message)
|
9
|
+
if ACTIVE_SUPPORT_DEFINED
|
10
|
+
external_callstack = caller_locations.reject do |location|
|
11
|
+
location.to_s.include?('gems/money')
|
12
|
+
end
|
13
|
+
active_support_deprecator.warn("[Shopify/Money] #{message}\n", external_callstack)
|
14
|
+
else
|
15
|
+
Kernel.warn("DEPRECATION WARNING: [Shopify/Money] #{message}\n")
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'bigdecimal'
|
3
|
+
|
4
|
+
class Money
|
5
|
+
module Helpers
|
6
|
+
module_function
|
7
|
+
|
8
|
+
NUMERIC_REGEX = /\A\s*[\+\-]?\d*(\.\d+)?\s*\z/
|
9
|
+
DECIMAL_ZERO = BigDecimal.new(0).freeze
|
10
|
+
MAX_DECIMAL = 21
|
11
|
+
|
12
|
+
def value_to_decimal(num)
|
13
|
+
value =
|
14
|
+
case num
|
15
|
+
when Money
|
16
|
+
num.value
|
17
|
+
when BigDecimal
|
18
|
+
num
|
19
|
+
when nil, 0, ''
|
20
|
+
DECIMAL_ZERO
|
21
|
+
when Integer
|
22
|
+
BigDecimal.new(num)
|
23
|
+
when Float
|
24
|
+
BigDecimal.new(num, Float::DIG)
|
25
|
+
when Rational
|
26
|
+
BigDecimal.new(num, MAX_DECIMAL)
|
27
|
+
when String
|
28
|
+
string_to_decimal(num)
|
29
|
+
else
|
30
|
+
raise ArgumentError, "could not parse as decimal #{num.inspect}"
|
31
|
+
end
|
32
|
+
return DECIMAL_ZERO if value.sign == BigDecimal::SIGN_NEGATIVE_ZERO
|
33
|
+
value
|
34
|
+
end
|
35
|
+
|
36
|
+
def value_to_currency(currency)
|
37
|
+
case currency
|
38
|
+
when Money::Currency, Money::NullCurrency
|
39
|
+
currency
|
40
|
+
when nil, ''
|
41
|
+
default = Money.current_currency || Money.default_currency
|
42
|
+
raise(ArgumentError, 'missing currency') if default.nil? || default == ''
|
43
|
+
value_to_currency(default)
|
44
|
+
when 'xxx', 'XXX'
|
45
|
+
Money::NULL_CURRENCY
|
46
|
+
when String
|
47
|
+
begin
|
48
|
+
Currency.find!(currency)
|
49
|
+
rescue Money::Currency::UnknownCurrency => error
|
50
|
+
Money.deprecate(error.message)
|
51
|
+
Money::NULL_CURRENCY
|
52
|
+
end
|
53
|
+
else
|
54
|
+
raise ArgumentError, "could not parse as currency #{currency.inspect}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def string_to_decimal(num)
|
59
|
+
if num =~ NUMERIC_REGEX
|
60
|
+
return BigDecimal.new(num)
|
61
|
+
end
|
62
|
+
|
63
|
+
Money.deprecate("using Money.new('#{num}') is deprecated and will raise an ArgumentError in the next major release")
|
64
|
+
begin
|
65
|
+
BigDecimal.new(num)
|
66
|
+
rescue ArgumentError
|
67
|
+
DECIMAL_ZERO
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
data/lib/money/money.rb
ADDED
@@ -0,0 +1,408 @@
|
|
1
|
+
class Money
|
2
|
+
include Comparable
|
3
|
+
extend Forwardable
|
4
|
+
|
5
|
+
NULL_CURRENCY = NullCurrency.new.freeze
|
6
|
+
|
7
|
+
attr_reader :value, :currency
|
8
|
+
def_delegators :@value, :zero?, :nonzero?, :positive?, :negative?, :to_i, :to_f, :hash
|
9
|
+
|
10
|
+
class << self
|
11
|
+
attr_accessor :parser, :default_currency
|
12
|
+
|
13
|
+
def new(value = 0, currency = nil)
|
14
|
+
value = Helpers.value_to_decimal(value)
|
15
|
+
currency = Helpers.value_to_currency(currency)
|
16
|
+
|
17
|
+
if value.zero?
|
18
|
+
@@zero_money ||= {}
|
19
|
+
@@zero_money[currency.iso_code] ||= super(Helpers::DECIMAL_ZERO, currency)
|
20
|
+
else
|
21
|
+
super(value, currency)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
alias_method :from_amount, :new
|
25
|
+
|
26
|
+
def zero
|
27
|
+
new(0, NULL_CURRENCY)
|
28
|
+
end
|
29
|
+
alias_method :empty, :zero
|
30
|
+
|
31
|
+
def parse(*args)
|
32
|
+
parser.parse(*args)
|
33
|
+
end
|
34
|
+
|
35
|
+
def from_cents(cents, currency = nil)
|
36
|
+
new(cents.round.to_f / 100, currency)
|
37
|
+
end
|
38
|
+
|
39
|
+
def from_subunits(subunits, currency_iso)
|
40
|
+
currency = Helpers.value_to_currency(currency_iso)
|
41
|
+
value = Helpers.value_to_decimal(subunits) / currency.subunit_to_unit
|
42
|
+
new(value, currency)
|
43
|
+
end
|
44
|
+
|
45
|
+
def rational(money1, money2)
|
46
|
+
money1.send(:arithmetic, money2) do
|
47
|
+
factor = money1.currency.subunit_to_unit * money2.currency.subunit_to_unit
|
48
|
+
Rational((money1.value * factor).to_i, (money2.value * factor).to_i)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def current_currency
|
53
|
+
Thread.current[:money_currency]
|
54
|
+
end
|
55
|
+
|
56
|
+
def current_currency=(currency)
|
57
|
+
Thread.current[:money_currency] = currency
|
58
|
+
end
|
59
|
+
|
60
|
+
# Set Money.default_currency inside the supplied block, resets it to
|
61
|
+
# the previous value when done to prevent leaking state. Similar to
|
62
|
+
# I18n.with_locale and ActiveSupport's Time.use_zone. This won't affect
|
63
|
+
# instances being created with explicitly set currency.
|
64
|
+
def with_currency(new_currency)
|
65
|
+
begin
|
66
|
+
old_currency = Money.current_currency
|
67
|
+
Money.current_currency = new_currency
|
68
|
+
yield
|
69
|
+
ensure
|
70
|
+
Money.current_currency = old_currency
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def default_settings
|
75
|
+
self.parser = MoneyParser
|
76
|
+
self.default_currency = Money::NULL_CURRENCY
|
77
|
+
end
|
78
|
+
end
|
79
|
+
default_settings
|
80
|
+
|
81
|
+
def initialize(value, currency)
|
82
|
+
raise ArgumentError if value.nan?
|
83
|
+
@currency = Helpers.value_to_currency(currency)
|
84
|
+
@value = value.round(@currency.minor_units)
|
85
|
+
freeze
|
86
|
+
end
|
87
|
+
|
88
|
+
def init_with(coder)
|
89
|
+
initialize(Helpers.value_to_decimal(coder['value']), coder['currency'])
|
90
|
+
end
|
91
|
+
|
92
|
+
def encode_with(coder)
|
93
|
+
coder['value'] = @value.to_s('F')
|
94
|
+
coder['currency'] = @currency.iso_code
|
95
|
+
end
|
96
|
+
|
97
|
+
def cents
|
98
|
+
# Money.deprecate('`money.cents` is deprecated and will be removed in the next major release. Please use `money.subunits` instead. Keep in mind, subunits are currency aware.')
|
99
|
+
(value * 100).to_i
|
100
|
+
end
|
101
|
+
|
102
|
+
def subunits
|
103
|
+
(@value * @currency.subunit_to_unit).to_i
|
104
|
+
end
|
105
|
+
|
106
|
+
def no_currency?
|
107
|
+
currency.is_a?(NullCurrency)
|
108
|
+
end
|
109
|
+
|
110
|
+
def -@
|
111
|
+
Money.new(-value, currency)
|
112
|
+
end
|
113
|
+
|
114
|
+
def <=>(other)
|
115
|
+
arithmetic(other) do |money|
|
116
|
+
value <=> money.value
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def +(other)
|
121
|
+
arithmetic(other) do |money|
|
122
|
+
Money.new(value + money.value, calculated_currency(money.currency))
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def -(other)
|
127
|
+
arithmetic(other) do |money|
|
128
|
+
Money.new(value - money.value, calculated_currency(money.currency))
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def *(numeric)
|
133
|
+
unless numeric.is_a?(Numeric)
|
134
|
+
Money.deprecate("Multiplying Money with #{numeric.class.name} is deprecated and will be removed in the next major release.")
|
135
|
+
end
|
136
|
+
Money.new(value.to_r * numeric, currency)
|
137
|
+
end
|
138
|
+
|
139
|
+
def /(numeric)
|
140
|
+
raise "[Money] Dividing money objects can lose pennies. Use #split instead"
|
141
|
+
end
|
142
|
+
|
143
|
+
def inspect
|
144
|
+
"#<#{self.class} value:#{self} currency:#{self.currency}>"
|
145
|
+
end
|
146
|
+
|
147
|
+
def ==(other)
|
148
|
+
eql?(other)
|
149
|
+
end
|
150
|
+
|
151
|
+
def eql?(other)
|
152
|
+
return false unless other.is_a?(Money)
|
153
|
+
return false unless currency.compatible?(other.currency)
|
154
|
+
value == other.value
|
155
|
+
end
|
156
|
+
|
157
|
+
class ReverseOperationProxy
|
158
|
+
include Comparable
|
159
|
+
|
160
|
+
def initialize(value)
|
161
|
+
@value = value
|
162
|
+
end
|
163
|
+
|
164
|
+
def <=>(other)
|
165
|
+
-(other <=> @value)
|
166
|
+
end
|
167
|
+
|
168
|
+
def +(other)
|
169
|
+
other + @value
|
170
|
+
end
|
171
|
+
|
172
|
+
def -(other)
|
173
|
+
-(other - @value)
|
174
|
+
end
|
175
|
+
|
176
|
+
def *(other)
|
177
|
+
other * @value
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def coerce(other)
|
182
|
+
raise TypeError, "Money can't be coerced into #{other.class}" unless other.is_a?(Numeric)
|
183
|
+
[ReverseOperationProxy.new(other), self]
|
184
|
+
end
|
185
|
+
|
186
|
+
def to_money(_currency = nil)
|
187
|
+
self
|
188
|
+
end
|
189
|
+
|
190
|
+
def to_d
|
191
|
+
value
|
192
|
+
end
|
193
|
+
|
194
|
+
def to_s(style = nil)
|
195
|
+
case style
|
196
|
+
when :legacy_dollars
|
197
|
+
sprintf("%.2f", value)
|
198
|
+
when :amount, nil
|
199
|
+
sprintf("%.#{currency.minor_units}f", value)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def to_liquid
|
204
|
+
cents
|
205
|
+
end
|
206
|
+
|
207
|
+
def to_json(options = {})
|
208
|
+
to_s
|
209
|
+
end
|
210
|
+
|
211
|
+
def as_json(*args)
|
212
|
+
to_s
|
213
|
+
end
|
214
|
+
|
215
|
+
def abs
|
216
|
+
Money.new(value.abs, currency)
|
217
|
+
end
|
218
|
+
|
219
|
+
def floor
|
220
|
+
Money.new(value.floor, currency)
|
221
|
+
end
|
222
|
+
|
223
|
+
def round(ndigits=0)
|
224
|
+
Money.new(value.round(ndigits), currency)
|
225
|
+
end
|
226
|
+
|
227
|
+
def fraction(rate)
|
228
|
+
raise ArgumentError, "rate should be positive" if rate < 0
|
229
|
+
|
230
|
+
result = value / (1 + rate)
|
231
|
+
Money.new(result, currency)
|
232
|
+
end
|
233
|
+
|
234
|
+
# Allocates money between different parties without losing pennies.
|
235
|
+
# After the mathematically split has been performed, left over pennies will
|
236
|
+
# be distributed round-robin amongst the parties. This means that parties
|
237
|
+
# listed first will likely receive more pennies than ones that are listed later
|
238
|
+
#
|
239
|
+
# @param splits [Array<Numeric>]
|
240
|
+
# @return [Array<Money>]
|
241
|
+
#
|
242
|
+
# @example
|
243
|
+
# Money.new(5, "USD").allocate([0.50, 0.25, 0.25])
|
244
|
+
# #=> [#<Money value:2.50 currency:USD>, #<Money value:1.25 currency:USD>, #<Money value:1.25 currency:USD>]
|
245
|
+
# Money.new(5, "USD").allocate([0.3, 0.7])
|
246
|
+
# #=> [#<Money value:1.50 currency:USD>, #<Money value:3.50 currency:USD>]
|
247
|
+
# Money.new(100, "USD").allocate([0.33, 0.33, 0.33])
|
248
|
+
# #=> [#<Money value:33.34 currency:USD>, #<Money value:33.33 currency:USD>, #<Money value:33.33 currency:USD>]
|
249
|
+
|
250
|
+
# @example left over cents distributed to first party due to rounding, and two solutions for a more natural distribution
|
251
|
+
# Money.new(30, "USD").allocate([0.667, 0.333])
|
252
|
+
# #=> [#<Money value:20.01 currency:USD>, #<Money value:9.99 currency:USD>]
|
253
|
+
# Money.new(30, "USD").allocate([0.333, 0.667])
|
254
|
+
# #=> [#<Money value:20.00 currency:USD>, #<Money value:10.00 currency:USD>]
|
255
|
+
# Money.new(30, "USD").allocate([Rational(2, 3), Rational(1, 3)])
|
256
|
+
# #=> [#<Money value:20.00 currency:USD>, #<Money value:10.00 currency:USD>]
|
257
|
+
def allocate(splits)
|
258
|
+
if all_rational?(splits)
|
259
|
+
allocations = splits.inject(0) { |sum, n| sum + n }
|
260
|
+
else
|
261
|
+
allocations = splits.inject(0) { |sum, n| sum + Helpers.value_to_decimal(n) }
|
262
|
+
end
|
263
|
+
|
264
|
+
if (allocations - BigDecimal("1")) > Float::EPSILON
|
265
|
+
raise ArgumentError, "splits add to more than 100%"
|
266
|
+
end
|
267
|
+
|
268
|
+
amounts, left_over = amounts_from_splits(allocations, splits)
|
269
|
+
|
270
|
+
left_over.to_i.times { |i| amounts[i % amounts.length] += 1 }
|
271
|
+
|
272
|
+
amounts.collect { |subunits| Money.from_subunits(subunits, currency) }
|
273
|
+
end
|
274
|
+
|
275
|
+
# Allocates money between different parties up to the maximum amounts specified.
|
276
|
+
# Left over pennies will be assigned round-robin up to the maximum specified.
|
277
|
+
# Pennies are dropped when the maximums are attained.
|
278
|
+
#
|
279
|
+
# @example
|
280
|
+
# Money.new(30.75).allocate_max_amounts([Money.new(26), Money.new(4.75)])
|
281
|
+
# #=> [Money.new(26), Money.new(4.75)]
|
282
|
+
#
|
283
|
+
# Money.new(30.75).allocate_max_amounts([Money.new(26), Money.new(4.74)]
|
284
|
+
# #=> [Money.new(26), Money.new(4.74)]
|
285
|
+
#
|
286
|
+
# Money.new(30).allocate_max_amounts([Money.new(15), Money.new(15)]
|
287
|
+
# #=> [Money.new(15), Money.new(15)]
|
288
|
+
#
|
289
|
+
# Money.new(1).allocate_max_amounts([Money.new(33), Money.new(33), Money.new(33)])
|
290
|
+
# #=> [Money.new(0.34), Money.new(0.33), Money.new(0.33)]
|
291
|
+
#
|
292
|
+
# Money.new(100).allocate_max_amounts([Money.new(5), Money.new(2)])
|
293
|
+
# #=> [Money.new(5), Money.new(2)]
|
294
|
+
def allocate_max_amounts(maximums)
|
295
|
+
allocation_currency = extract_currency(maximums + [self])
|
296
|
+
maximums = maximums.map { |max| max.to_money(allocation_currency) }
|
297
|
+
maximums_total = maximums.reduce(Money.new(0, allocation_currency), :+)
|
298
|
+
|
299
|
+
splits = maximums.map do |max_amount|
|
300
|
+
next(0) if maximums_total.zero?
|
301
|
+
Money.rational(max_amount, maximums_total)
|
302
|
+
end
|
303
|
+
|
304
|
+
total_allocatable = [
|
305
|
+
value * allocation_currency.subunit_to_unit,
|
306
|
+
maximums_total.value * allocation_currency.subunit_to_unit
|
307
|
+
].min
|
308
|
+
|
309
|
+
subunits_amounts, left_over = amounts_from_splits(1, splits, total_allocatable)
|
310
|
+
|
311
|
+
subunits_amounts.each_with_index do |amount, index|
|
312
|
+
break unless left_over > 0
|
313
|
+
|
314
|
+
max_amount = maximums[index].value * allocation_currency.subunit_to_unit
|
315
|
+
next unless amount < max_amount
|
316
|
+
|
317
|
+
left_over -= 1
|
318
|
+
subunits_amounts[index] += 1
|
319
|
+
end
|
320
|
+
|
321
|
+
subunits_amounts.map { |cents| Money.from_subunits(cents, allocation_currency) }
|
322
|
+
end
|
323
|
+
|
324
|
+
# Split money amongst parties evenly without losing pennies.
|
325
|
+
#
|
326
|
+
# @param [2] number of parties.
|
327
|
+
#
|
328
|
+
# @return [Array<Money, Money, Money>]
|
329
|
+
#
|
330
|
+
# @example
|
331
|
+
# Money.new(100, "USD").split(3) #=> [Money.new(34), Money.new(33), Money.new(33)]
|
332
|
+
def split(num)
|
333
|
+
raise ArgumentError, "need at least one party" if num < 1
|
334
|
+
subunits = self.subunits
|
335
|
+
low = Money.from_subunits(subunits / num, currency)
|
336
|
+
high = Money.from_subunits(low.subunits + 1, currency)
|
337
|
+
|
338
|
+
remainder = subunits % num
|
339
|
+
result = []
|
340
|
+
|
341
|
+
num.times do |index|
|
342
|
+
result[index] = index < remainder ? high : low
|
343
|
+
end
|
344
|
+
|
345
|
+
return result
|
346
|
+
end
|
347
|
+
|
348
|
+
# Clamps the value to be within the specified minimum and maximum. Returns
|
349
|
+
# self if the value is within bounds, otherwise a new Money object with the
|
350
|
+
# closest min or max value.
|
351
|
+
#
|
352
|
+
# @example
|
353
|
+
# Money.new(50, "CAD").clamp(1, 100) #=> Money.new(50, "CAD")
|
354
|
+
#
|
355
|
+
# Money.new(120, "CAD").clamp(0, 100) #=> Money.new(100, "CAD")
|
356
|
+
def clamp(min, max)
|
357
|
+
raise ArgumentError, 'min cannot be greater than max' if min > max
|
358
|
+
|
359
|
+
clamped_value = min if self.value < min
|
360
|
+
clamped_value = max if self.value > max
|
361
|
+
|
362
|
+
if clamped_value.nil?
|
363
|
+
self
|
364
|
+
else
|
365
|
+
Money.new(clamped_value, self.currency)
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
private
|
370
|
+
|
371
|
+
def all_rational?(splits)
|
372
|
+
splits.all? { |split| split.is_a?(Rational) }
|
373
|
+
end
|
374
|
+
|
375
|
+
def amounts_from_splits(allocations, splits, subunits_to_split = subunits)
|
376
|
+
left_over = subunits_to_split
|
377
|
+
|
378
|
+
amounts = splits.collect do |ratio|
|
379
|
+
frac = (Helpers.value_to_decimal(subunits_to_split * ratio) / allocations).floor
|
380
|
+
left_over -= frac
|
381
|
+
frac
|
382
|
+
end
|
383
|
+
|
384
|
+
[amounts, left_over]
|
385
|
+
end
|
386
|
+
|
387
|
+
def arithmetic(money_or_numeric)
|
388
|
+
raise TypeError, "#{money_or_numeric.class.name} can't be coerced into Money" unless money_or_numeric.respond_to?(:to_money)
|
389
|
+
other = money_or_numeric.to_money(currency)
|
390
|
+
|
391
|
+
unless currency.compatible?(other.currency)
|
392
|
+
Money.deprecate("mathematical operation not permitted for Money objects with different currencies #{other.currency} and #{currency}.")
|
393
|
+
end
|
394
|
+
yield(other)
|
395
|
+
end
|
396
|
+
|
397
|
+
def calculated_currency(other)
|
398
|
+
no_currency? ? other : currency
|
399
|
+
end
|
400
|
+
|
401
|
+
def extract_currency(money_array)
|
402
|
+
currencies = money_array.lazy.select { |money| money.is_a?(Money) }.reject(&:no_currency?).map(&:currency).to_a.uniq
|
403
|
+
if currencies.size > 1
|
404
|
+
raise ArgumentError, "operation not permitted for Money objects with different currencies #{currencies.join(', ')}"
|
405
|
+
end
|
406
|
+
currencies.first || NULL_CURRENCY
|
407
|
+
end
|
408
|
+
end
|