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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +2 -3
  3. data/Gemfile +2 -1
  4. data/README.md +20 -1
  5. data/Rakefile +1 -0
  6. data/bin/console +1 -0
  7. data/config/currency_iso.json +1 -1
  8. data/dev.yml +1 -1
  9. data/lib/money.rb +4 -0
  10. data/lib/money/allocator.rb +144 -0
  11. data/lib/money/core_extensions.rb +1 -0
  12. data/lib/money/currency.rb +1 -0
  13. data/lib/money/currency/loader.rb +1 -0
  14. data/lib/money/deprecations.rb +1 -0
  15. data/lib/money/errors.rb +1 -0
  16. data/lib/money/helpers.rb +5 -15
  17. data/lib/money/money.rb +8 -111
  18. data/lib/money/null_currency.rb +1 -0
  19. data/lib/money/version.rb +2 -1
  20. data/lib/money_accessor.rb +1 -0
  21. data/lib/money_column.rb +1 -0
  22. data/lib/money_column/active_record_hooks.rb +2 -1
  23. data/lib/money_column/active_record_type.rb +1 -0
  24. data/lib/money_column/railtie.rb +1 -0
  25. data/lib/rubocop/cop/money.rb +3 -0
  26. data/lib/rubocop/cop/money/missing_currency.rb +75 -0
  27. data/lib/shopify-money.rb +2 -0
  28. data/money.gemspec +7 -3
  29. data/spec/accounting_money_parser_spec.rb +1 -0
  30. data/spec/allocator_spec.rb +148 -0
  31. data/spec/core_extensions_spec.rb +2 -1
  32. data/spec/currency/loader_spec.rb +1 -0
  33. data/spec/currency_spec.rb +1 -0
  34. data/spec/helpers_spec.rb +1 -6
  35. data/spec/money_accessor_spec.rb +1 -0
  36. data/spec/money_column_spec.rb +1 -0
  37. data/spec/money_parser_spec.rb +1 -0
  38. data/spec/money_spec.rb +13 -96
  39. data/spec/null_currency_spec.rb +1 -0
  40. data/spec/rubocop/cop/money/missing_currency_spec.rb +108 -0
  41. data/spec/rubocop_helper.rb +10 -0
  42. data/spec/schema.rb +1 -0
  43. data/spec/spec_helper.rb +1 -5
  44. metadata +20 -24
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e95441c6aa9e01d7ac17c8e76f8008cf94dba8d4af2bd75f101d743a333311e
4
- data.tar.gz: 07d99a0656016292183ef0a782879f018d4a8fdb814ab6e97470675003280805
3
+ metadata.gz: 9b719c9a9971dd875ee577e791f2caf49c146c9a08cdc5f9b415a8b1359645fc
4
+ data.tar.gz: f1e2d5250c520dd76491f2adacb6d6a9f99f91ae2fc1529c9fe9d58589d4172b
5
5
  SHA512:
6
- metadata.gz: e18f193d30427adb99f4ebb28e1125a5819ec489801c25698069f1c48448edb492ea3effbd9117bac45d77a613f59688a5ff5ed3327d4b20d03b6dd0a51231d7
7
- data.tar.gz: b5cc2766b02db02ebf0df626cc531d0996444f78e7939c7fee7e3caa050cb2fb06c767e88e7256ad12131f225436c68b7ebf776c6e8ea1995e18525004f34896
6
+ metadata.gz: 907fa206d1f4405be598d50f7d72591ab6f048c1b2b454a281d1789d958ef9ff659aacf2a440f6c7ec29f7e607e793667edb0e7dd29cd411688cb8899d917371
7
+ data.tar.gz: 809321fe1d30b417bed08a20719d576eb28f478edaa00e919dea0be995bc37897534563574404e689a1da9f8ce374908abb84d6ce426b07f45df039e30674f7c
@@ -5,9 +5,8 @@ branches:
5
5
  only:
6
6
  - master
7
7
  rvm:
8
- - 2.5
9
- - 2.4
10
- - 2.3
8
+ - 2.7
9
+ - 2.6
11
10
  before_install:
12
11
  # https://github.com/travis-ci/travis-ci/issues/8978#issuecomment-354036443
13
12
  - gem update --system
data/Gemfile CHANGED
@@ -1,6 +1,7 @@
1
+ # frozen_string_literal: true
1
2
  source "https://rubygems.org"
2
3
 
3
4
  gem "pry-byebug", require: false
4
- gem 'codecov', require: false
5
+ gem 'rubocop', "~> 0.81.0", require: false
5
6
 
6
7
  gemspec
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) [![codecov](https://codecov.io/gh/Shopify/money/branch/master/graph/badge.svg)](https://codecov.io/gh/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
@@ -1,4 +1,5 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  begin
4
5
  require 'bundler/gem_tasks'
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "bundler/setup"
4
5
  require "money"
@@ -934,7 +934,7 @@
934
934
  "symbol": "Ft",
935
935
  "alternate_symbols": [],
936
936
  "subunit": "",
937
- "subunit_to_unit": 1,
937
+ "subunit_to_unit": 100,
938
938
  "symbol_first": false,
939
939
  "html_entity": "",
940
940
  "decimal_mark": ",",
data/dev.yml CHANGED
@@ -3,7 +3,7 @@
3
3
  ---
4
4
  name: money
5
5
  up:
6
- - ruby: 2.5.3
6
+ - ruby: 2.6.6
7
7
  - bundler
8
8
  commands:
9
9
  test: bundle exec rspec
@@ -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
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  # Allows Writing of 100.to_money for +Numeric+ types
2
3
  # 100.to_money => #<Money @cents=10000>
3
4
  # 100.37.to_money => #<Money @cents=10037>
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "money/currency/loader"
2
3
 
3
4
  class Money
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'json'
2
3
 
3
4
  class Money
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  Money.class_eval do
2
3
  ACTIVE_SUPPORT_DEFINED = defined?(ActiveSupport)
3
4
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  class Money
2
3
  class Error < StandardError
3
4
  end
@@ -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
- string_to_decimal(num)
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
@@ -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
- # Allocates money between different parties without losing pennies.
249
- # After the mathematically split has been performed, left over pennies will
250
- # be distributed round-robin amongst the parties. This means that parties
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
- # Allocates money between different parties up to the maximum amounts specified.
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
- allocation_currency = extract_currency(maximums + [self])
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