shopify-money 0.10.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|