shopify-money 0.12.0 → 0.14.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
-
[![Build Status](https://travis-ci.org/Shopify/money.svg?branch=master)](https://travis-ci.org/Shopify/money)
|
3
|
+
[![Build Status](https://travis-ci.org/Shopify/money.svg?branch=master)](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
|