shopify-money 0.12.0 → 0.14.2
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 +4 -4
- data/.travis.yml +2 -3
- data/Gemfile +2 -1
- data/README.md +20 -1
- data/Rakefile +1 -0
- data/bin/console +1 -0
- data/config/currency_iso.json +1 -1
- data/dev.yml +1 -1
- data/lib/money.rb +4 -0
- data/lib/money/allocator.rb +144 -0
- data/lib/money/core_extensions.rb +1 -0
- data/lib/money/currency.rb +1 -0
- data/lib/money/currency/loader.rb +1 -0
- data/lib/money/deprecations.rb +1 -0
- data/lib/money/errors.rb +1 -0
- data/lib/money/helpers.rb +5 -15
- data/lib/money/money.rb +8 -111
- data/lib/money/null_currency.rb +1 -0
- data/lib/money/version.rb +2 -1
- data/lib/money_accessor.rb +1 -0
- data/lib/money_column.rb +1 -0
- data/lib/money_column/active_record_hooks.rb +2 -1
- data/lib/money_column/active_record_type.rb +1 -0
- data/lib/money_column/railtie.rb +1 -0
- data/lib/rubocop/cop/money.rb +3 -0
- data/lib/rubocop/cop/money/missing_currency.rb +75 -0
- data/lib/shopify-money.rb +2 -0
- data/money.gemspec +7 -3
- data/spec/accounting_money_parser_spec.rb +1 -0
- data/spec/allocator_spec.rb +148 -0
- data/spec/core_extensions_spec.rb +2 -1
- data/spec/currency/loader_spec.rb +1 -0
- data/spec/currency_spec.rb +1 -0
- data/spec/helpers_spec.rb +1 -6
- data/spec/money_accessor_spec.rb +1 -0
- data/spec/money_column_spec.rb +1 -0
- data/spec/money_parser_spec.rb +1 -0
- data/spec/money_spec.rb +13 -96
- data/spec/null_currency_spec.rb +1 -0
- data/spec/rubocop/cop/money/missing_currency_spec.rb +108 -0
- data/spec/rubocop_helper.rb +10 -0
- data/spec/schema.rb +1 -0
- data/spec/spec_helper.rb +1 -5
- metadata +20 -24
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9b719c9a9971dd875ee577e791f2caf49c146c9a08cdc5f9b415a8b1359645fc
|
4
|
+
data.tar.gz: f1e2d5250c520dd76491f2adacb6d6a9f99f91ae2fc1529c9fe9d58589d4172b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 907fa206d1f4405be598d50f7d72591ab6f048c1b2b454a281d1789d958ef9ff659aacf2a440f6c7ec29f7e607e793667edb0e7dd29cd411688cb8899d917371
|
7
|
+
data.tar.gz: 809321fe1d30b417bed08a20719d576eb28f478edaa00e919dea0be995bc37897534563574404e689a1da9f8ce374908abb84d6ce426b07f45df039e30674f7c
|
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# money
|
2
2
|
|
3
|
-
[](https://travis-ci.org/Shopify/money)
|
3
|
+
[](https://travis-ci.org/Shopify/money)
|
4
4
|
|
5
5
|
|
6
6
|
money_column expects a DECIMAL(21,3) database field.
|
@@ -139,6 +139,25 @@ currency on the model or attribute level.
|
|
139
139
|
There are no validations generated. You can add these for the specified money
|
140
140
|
and currency attributes as you normally would for any other.
|
141
141
|
|
142
|
+
## Rubocop
|
143
|
+
|
144
|
+
A RuboCop rule to enforce the presence of a currency using static analysis is available.
|
145
|
+
|
146
|
+
Add to your `.rubocop.yml`
|
147
|
+
```yaml
|
148
|
+
require:
|
149
|
+
- money
|
150
|
+
|
151
|
+
Money/MissingCurrency:
|
152
|
+
Enabled: true
|
153
|
+
```
|
154
|
+
|
155
|
+
If your application is currently handling only one currency, it can autocorrect this by specifying a currency under the `Enabled` line:
|
156
|
+
|
157
|
+
```yaml
|
158
|
+
ReplacementCurrency: 'CAD'
|
159
|
+
```
|
160
|
+
|
142
161
|
## Contributing to money
|
143
162
|
|
144
163
|
- Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet
|
data/Rakefile
CHANGED
data/bin/console
CHANGED
data/config/currency_iso.json
CHANGED
data/dev.yml
CHANGED
data/lib/money.rb
CHANGED
@@ -1,7 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
require_relative 'money/money_parser'
|
2
3
|
require_relative 'money/helpers'
|
3
4
|
require_relative 'money/currency'
|
4
5
|
require_relative 'money/null_currency'
|
6
|
+
require_relative 'money/allocator'
|
5
7
|
require_relative 'money/money'
|
6
8
|
require_relative 'money/errors'
|
7
9
|
require_relative 'money/deprecations'
|
@@ -9,3 +11,5 @@ require_relative 'money/accounting_money_parser'
|
|
9
11
|
require_relative 'money/core_extensions'
|
10
12
|
require_relative 'money_accessor'
|
11
13
|
require_relative 'money_column' if defined?(ActiveRecord)
|
14
|
+
|
15
|
+
require_relative 'rubocop/cop/money' if defined?(RuboCop)
|
@@ -0,0 +1,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'delegate'
|
3
|
+
|
4
|
+
class Money
|
5
|
+
class Allocator < SimpleDelegator
|
6
|
+
def initialize(money)
|
7
|
+
super
|
8
|
+
end
|
9
|
+
|
10
|
+
# Allocates money between different parties without losing pennies.
|
11
|
+
# After the mathematically split has been performed, left over pennies will
|
12
|
+
# be distributed round-robin amongst the parties. This means that parties
|
13
|
+
# listed first will likely receive more pennies than ones that are listed later
|
14
|
+
#
|
15
|
+
# @param splits [Array<Numeric>]
|
16
|
+
# @param strategy Symbol
|
17
|
+
# @return [Array<Money>]
|
18
|
+
#
|
19
|
+
# Strategies:
|
20
|
+
# - `:roundrobin` (default): leftover pennies will be accumulated starting from the first allocation left to right
|
21
|
+
# - `:roundrobin_reverse`: leftover pennies will be accumulated starting from the last allocation right to left
|
22
|
+
#
|
23
|
+
# @example
|
24
|
+
# Money.new(5, "USD").allocate([0.50, 0.25, 0.25])
|
25
|
+
# #=> [#<Money value:2.50 currency:USD>, #<Money value:1.25 currency:USD>, #<Money value:1.25 currency:USD>]
|
26
|
+
# Money.new(5, "USD").allocate([0.3, 0.7])
|
27
|
+
# #=> [#<Money value:1.50 currency:USD>, #<Money value:3.50 currency:USD>]
|
28
|
+
# Money.new(100, "USD").allocate([0.33, 0.33, 0.33])
|
29
|
+
# #=> [#<Money value:33.34 currency:USD>, #<Money value:33.33 currency:USD>, #<Money value:33.33 currency:USD>]
|
30
|
+
|
31
|
+
# @example left over cents distributed to first party due to rounding, and two solutions for a more natural distribution
|
32
|
+
# Money.new(30, "USD").allocate([0.667, 0.333])
|
33
|
+
# #=> [#<Money value:20.01 currency:USD>, #<Money value:9.99 currency:USD>]
|
34
|
+
# Money.new(30, "USD").allocate([0.333, 0.667])
|
35
|
+
# #=> [#<Money value:20.00 currency:USD>, #<Money value:10.00 currency:USD>]
|
36
|
+
# Money.new(30, "USD").allocate([Rational(2, 3), Rational(1, 3)])
|
37
|
+
# #=> [#<Money value:20.00 currency:USD>, #<Money value:10.00 currency:USD>]
|
38
|
+
|
39
|
+
# @example left over pennies distributed reverse order when using roundrobin_reverse strategy
|
40
|
+
# Money.new(10.01, "USD").allocate([0.5, 0.5], :roundrobin_reverse)
|
41
|
+
# #=> [#<Money value:5.00 currency:USD>, #<Money value:5.01 currency:USD>]
|
42
|
+
def allocate(splits, strategy = :roundrobin)
|
43
|
+
splits.map!(&:to_r)
|
44
|
+
allocations = splits.inject(0, :+)
|
45
|
+
|
46
|
+
if (allocations - BigDecimal("1")) > Float::EPSILON
|
47
|
+
raise ArgumentError, "splits add to more than 100%"
|
48
|
+
end
|
49
|
+
|
50
|
+
amounts, left_over = amounts_from_splits(allocations, splits)
|
51
|
+
|
52
|
+
left_over.to_i.times do |i|
|
53
|
+
amounts[allocation_index_for(strategy, amounts.length, i)] += 1
|
54
|
+
end
|
55
|
+
|
56
|
+
amounts.collect { |subunits| Money.from_subunits(subunits, currency) }
|
57
|
+
end
|
58
|
+
|
59
|
+
# Allocates money between different parties up to the maximum amounts specified.
|
60
|
+
# Left over pennies will be assigned round-robin up to the maximum specified.
|
61
|
+
# Pennies are dropped when the maximums are attained.
|
62
|
+
#
|
63
|
+
# @example
|
64
|
+
# Money.new(30.75).allocate_max_amounts([Money.new(26), Money.new(4.75)])
|
65
|
+
# #=> [Money.new(26), Money.new(4.75)]
|
66
|
+
#
|
67
|
+
# Money.new(30.75).allocate_max_amounts([Money.new(26), Money.new(4.74)]
|
68
|
+
# #=> [Money.new(26), Money.new(4.74)]
|
69
|
+
#
|
70
|
+
# Money.new(30).allocate_max_amounts([Money.new(15), Money.new(15)]
|
71
|
+
# #=> [Money.new(15), Money.new(15)]
|
72
|
+
#
|
73
|
+
# Money.new(1).allocate_max_amounts([Money.new(33), Money.new(33), Money.new(33)])
|
74
|
+
# #=> [Money.new(0.34), Money.new(0.33), Money.new(0.33)]
|
75
|
+
#
|
76
|
+
# Money.new(100).allocate_max_amounts([Money.new(5), Money.new(2)])
|
77
|
+
# #=> [Money.new(5), Money.new(2)]
|
78
|
+
def allocate_max_amounts(maximums)
|
79
|
+
allocation_currency = extract_currency(maximums + [self.__getobj__])
|
80
|
+
maximums = maximums.map { |max| max.to_money(allocation_currency) }
|
81
|
+
maximums_total = maximums.reduce(Money.new(0, allocation_currency), :+)
|
82
|
+
|
83
|
+
splits = maximums.map do |max_amount|
|
84
|
+
next(Rational(0)) if maximums_total.zero?
|
85
|
+
Money.rational(max_amount, maximums_total)
|
86
|
+
end
|
87
|
+
|
88
|
+
total_allocatable = [maximums_total.subunits, self.subunits].min
|
89
|
+
|
90
|
+
subunits_amounts, left_over = amounts_from_splits(1, splits, total_allocatable)
|
91
|
+
|
92
|
+
subunits_amounts.each_with_index do |amount, index|
|
93
|
+
break unless left_over > 0
|
94
|
+
|
95
|
+
max_amount = maximums[index].value * allocation_currency.subunit_to_unit
|
96
|
+
next unless amount < max_amount
|
97
|
+
|
98
|
+
left_over -= 1
|
99
|
+
subunits_amounts[index] += 1
|
100
|
+
end
|
101
|
+
|
102
|
+
subunits_amounts.map { |cents| Money.from_subunits(cents, allocation_currency) }
|
103
|
+
end
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
def extract_currency(money_array)
|
108
|
+
currencies = money_array.lazy.select { |money| money.is_a?(Money) }.reject(&:no_currency?).map(&:currency).to_a.uniq
|
109
|
+
if currencies.size > 1
|
110
|
+
raise ArgumentError, "operation not permitted for Money objects with different currencies #{currencies.join(', ')}"
|
111
|
+
end
|
112
|
+
currencies.first || NULL_CURRENCY
|
113
|
+
end
|
114
|
+
|
115
|
+
def amounts_from_splits(allocations, splits, subunits_to_split = subunits)
|
116
|
+
raise ArgumentError, "All splits values must be of type Rational." unless all_rational?(splits)
|
117
|
+
|
118
|
+
left_over = subunits_to_split
|
119
|
+
|
120
|
+
amounts = splits.collect do |ratio|
|
121
|
+
frac = (subunits_to_split * ratio / allocations.to_r).floor
|
122
|
+
left_over -= frac
|
123
|
+
frac
|
124
|
+
end
|
125
|
+
|
126
|
+
[amounts, left_over]
|
127
|
+
end
|
128
|
+
|
129
|
+
def all_rational?(splits)
|
130
|
+
splits.all? { |split| split.is_a?(Rational) }
|
131
|
+
end
|
132
|
+
|
133
|
+
def allocation_index_for(strategy, length, idx)
|
134
|
+
case strategy
|
135
|
+
when :roundrobin
|
136
|
+
idx % length
|
137
|
+
when :roundrobin_reverse
|
138
|
+
length - (idx % length) - 1
|
139
|
+
else
|
140
|
+
raise ArgumentError, "Invalid strategy. Valid options: :roundrobin, :roundrobin_reverse"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
data/lib/money/currency.rb
CHANGED
data/lib/money/deprecations.rb
CHANGED
data/lib/money/errors.rb
CHANGED
data/lib/money/helpers.rb
CHANGED
@@ -5,7 +5,6 @@ class Money
|
|
5
5
|
module Helpers
|
6
6
|
module_function
|
7
7
|
|
8
|
-
NUMERIC_REGEX = /\A\s*[\+\-]?(\d+|\d*\.\d+)\s*\z/
|
9
8
|
DECIMAL_ZERO = BigDecimal(0).freeze
|
10
9
|
MAX_DECIMAL = 21
|
11
10
|
|
@@ -25,7 +24,11 @@ class Money
|
|
25
24
|
when Rational
|
26
25
|
BigDecimal(num, MAX_DECIMAL)
|
27
26
|
when String
|
28
|
-
|
27
|
+
decimal = BigDecimal(num, exception: false)
|
28
|
+
return decimal if decimal
|
29
|
+
|
30
|
+
Money.deprecate("using Money.new('#{num}') is deprecated and will raise an ArgumentError in the next major release")
|
31
|
+
DECIMAL_ZERO
|
29
32
|
else
|
30
33
|
raise ArgumentError, "could not parse as decimal #{num.inspect}"
|
31
34
|
end
|
@@ -54,18 +57,5 @@ class Money
|
|
54
57
|
raise ArgumentError, "could not parse as currency #{currency.inspect}"
|
55
58
|
end
|
56
59
|
end
|
57
|
-
|
58
|
-
def string_to_decimal(num)
|
59
|
-
if num =~ NUMERIC_REGEX
|
60
|
-
return BigDecimal(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(num)
|
66
|
-
rescue ArgumentError
|
67
|
-
DECIMAL_ZERO
|
68
|
-
end
|
69
|
-
end
|
70
60
|
end
|
71
61
|
end
|
data/lib/money/money.rb
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
require 'forwardable'
|
2
3
|
|
3
4
|
class Money
|
@@ -30,8 +31,8 @@ class Money
|
|
30
31
|
end
|
31
32
|
alias_method :empty, :zero
|
32
33
|
|
33
|
-
def parse(*args)
|
34
|
-
parser.parse(*args)
|
34
|
+
def parse(*args, **kwargs)
|
35
|
+
parser.parse(*args, **kwargs)
|
35
36
|
end
|
36
37
|
|
37
38
|
def from_cents(cents, currency = nil)
|
@@ -245,94 +246,14 @@ class Money
|
|
245
246
|
Money.new(result, currency)
|
246
247
|
end
|
247
248
|
|
248
|
-
#
|
249
|
-
|
250
|
-
|
251
|
-
# listed first will likely receive more pennies than ones that are listed later
|
252
|
-
#
|
253
|
-
# @param splits [Array<Numeric>]
|
254
|
-
# @return [Array<Money>]
|
255
|
-
#
|
256
|
-
# @example
|
257
|
-
# Money.new(5, "USD").allocate([0.50, 0.25, 0.25])
|
258
|
-
# #=> [#<Money value:2.50 currency:USD>, #<Money value:1.25 currency:USD>, #<Money value:1.25 currency:USD>]
|
259
|
-
# Money.new(5, "USD").allocate([0.3, 0.7])
|
260
|
-
# #=> [#<Money value:1.50 currency:USD>, #<Money value:3.50 currency:USD>]
|
261
|
-
# Money.new(100, "USD").allocate([0.33, 0.33, 0.33])
|
262
|
-
# #=> [#<Money value:33.34 currency:USD>, #<Money value:33.33 currency:USD>, #<Money value:33.33 currency:USD>]
|
263
|
-
|
264
|
-
# @example left over cents distributed to first party due to rounding, and two solutions for a more natural distribution
|
265
|
-
# Money.new(30, "USD").allocate([0.667, 0.333])
|
266
|
-
# #=> [#<Money value:20.01 currency:USD>, #<Money value:9.99 currency:USD>]
|
267
|
-
# Money.new(30, "USD").allocate([0.333, 0.667])
|
268
|
-
# #=> [#<Money value:20.00 currency:USD>, #<Money value:10.00 currency:USD>]
|
269
|
-
# Money.new(30, "USD").allocate([Rational(2, 3), Rational(1, 3)])
|
270
|
-
# #=> [#<Money value:20.00 currency:USD>, #<Money value:10.00 currency:USD>]
|
271
|
-
def allocate(splits)
|
272
|
-
if all_rational?(splits)
|
273
|
-
allocations = splits.inject(0) { |sum, n| sum + n }
|
274
|
-
else
|
275
|
-
allocations = splits.inject(0) { |sum, n| sum + Helpers.value_to_decimal(n) }
|
276
|
-
end
|
277
|
-
|
278
|
-
if (allocations - BigDecimal("1")) > Float::EPSILON
|
279
|
-
raise ArgumentError, "splits add to more than 100%"
|
280
|
-
end
|
281
|
-
|
282
|
-
amounts, left_over = amounts_from_splits(allocations, splits)
|
283
|
-
|
284
|
-
left_over.to_i.times { |i| amounts[i % amounts.length] += 1 }
|
285
|
-
|
286
|
-
amounts.collect { |subunits| Money.from_subunits(subunits, currency) }
|
249
|
+
# @see Money::Allocator#allocate
|
250
|
+
def allocate(splits, strategy = :roundrobin)
|
251
|
+
Money::Allocator.new(self).allocate(splits, strategy)
|
287
252
|
end
|
288
253
|
|
289
|
-
#
|
290
|
-
# Left over pennies will be assigned round-robin up to the maximum specified.
|
291
|
-
# Pennies are dropped when the maximums are attained.
|
292
|
-
#
|
293
|
-
# @example
|
294
|
-
# Money.new(30.75).allocate_max_amounts([Money.new(26), Money.new(4.75)])
|
295
|
-
# #=> [Money.new(26), Money.new(4.75)]
|
296
|
-
#
|
297
|
-
# Money.new(30.75).allocate_max_amounts([Money.new(26), Money.new(4.74)]
|
298
|
-
# #=> [Money.new(26), Money.new(4.74)]
|
299
|
-
#
|
300
|
-
# Money.new(30).allocate_max_amounts([Money.new(15), Money.new(15)]
|
301
|
-
# #=> [Money.new(15), Money.new(15)]
|
302
|
-
#
|
303
|
-
# Money.new(1).allocate_max_amounts([Money.new(33), Money.new(33), Money.new(33)])
|
304
|
-
# #=> [Money.new(0.34), Money.new(0.33), Money.new(0.33)]
|
305
|
-
#
|
306
|
-
# Money.new(100).allocate_max_amounts([Money.new(5), Money.new(2)])
|
307
|
-
# #=> [Money.new(5), Money.new(2)]
|
254
|
+
# @see Money::Allocator#allocate_max_amounts
|
308
255
|
def allocate_max_amounts(maximums)
|
309
|
-
|
310
|
-
maximums = maximums.map { |max| max.to_money(allocation_currency) }
|
311
|
-
maximums_total = maximums.reduce(Money.new(0, allocation_currency), :+)
|
312
|
-
|
313
|
-
splits = maximums.map do |max_amount|
|
314
|
-
next(0) if maximums_total.zero?
|
315
|
-
Money.rational(max_amount, maximums_total)
|
316
|
-
end
|
317
|
-
|
318
|
-
total_allocatable = [
|
319
|
-
value * allocation_currency.subunit_to_unit,
|
320
|
-
maximums_total.value * allocation_currency.subunit_to_unit
|
321
|
-
].min
|
322
|
-
|
323
|
-
subunits_amounts, left_over = amounts_from_splits(1, splits, total_allocatable)
|
324
|
-
|
325
|
-
subunits_amounts.each_with_index do |amount, index|
|
326
|
-
break unless left_over > 0
|
327
|
-
|
328
|
-
max_amount = maximums[index].value * allocation_currency.subunit_to_unit
|
329
|
-
next unless amount < max_amount
|
330
|
-
|
331
|
-
left_over -= 1
|
332
|
-
subunits_amounts[index] += 1
|
333
|
-
end
|
334
|
-
|
335
|
-
subunits_amounts.map { |cents| Money.from_subunits(cents, allocation_currency) }
|
256
|
+
Money::Allocator.new(self).allocate_max_amounts(maximums)
|
336
257
|
end
|
337
258
|
|
338
259
|
# Split money amongst parties evenly without losing pennies.
|
@@ -395,22 +316,6 @@ class Money
|
|
395
316
|
|
396
317
|
private
|
397
318
|
|
398
|
-
def all_rational?(splits)
|
399
|
-
splits.all? { |split| split.is_a?(Rational) }
|
400
|
-
end
|
401
|
-
|
402
|
-
def amounts_from_splits(allocations, splits, subunits_to_split = subunits)
|
403
|
-
left_over = subunits_to_split
|
404
|
-
|
405
|
-
amounts = splits.collect do |ratio|
|
406
|
-
frac = (Helpers.value_to_decimal(subunits_to_split * ratio) / allocations).floor
|
407
|
-
left_over -= frac
|
408
|
-
frac
|
409
|
-
end
|
410
|
-
|
411
|
-
[amounts, left_over]
|
412
|
-
end
|
413
|
-
|
414
319
|
def arithmetic(money_or_numeric)
|
415
320
|
raise TypeError, "#{money_or_numeric.class.name} can't be coerced into Money" unless money_or_numeric.respond_to?(:to_money)
|
416
321
|
other = money_or_numeric.to_money(currency)
|
@@ -421,12 +326,4 @@ class Money
|
|
421
326
|
def calculated_currency(other)
|
422
327
|
no_currency? ? other : currency
|
423
328
|
end
|
424
|
-
|
425
|
-
def extract_currency(money_array)
|
426
|
-
currencies = money_array.lazy.select { |money| money.is_a?(Money) }.reject(&:no_currency?).map(&:currency).to_a.uniq
|
427
|
-
if currencies.size > 1
|
428
|
-
raise ArgumentError, "operation not permitted for Money objects with different currencies #{currencies.join(', ')}"
|
429
|
-
end
|
430
|
-
currencies.first || NULL_CURRENCY
|
431
|
-
end
|
432
329
|
end
|