shopify-money 1.0.1.pre → 1.0.2.pre

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae5e66176d7c6e79d5c6346a3ca6915489d3750d7bb67aa68973c52a0198cb3f
4
- data.tar.gz: 7219d1edd8665498e64eb06e6212028d8ec559dd5ce9206cbe698e5b1e42d2ed
3
+ metadata.gz: 23562c524ba9f0d8219d01779505a75309d8825ed8f8839f1efdd71fbf5a1e89
4
+ data.tar.gz: ca1b6ea90e7a556970d135bbebc74c9cf6eb417cd475607ef58923751e2969b3
5
5
  SHA512:
6
- metadata.gz: 4188d1251f442d347b287b3b3ac69c08f716b149ef9cc8a78f2c8e75fb563524f3818a9b8116cf2d433117a9e66cbb96b428aed9bf8cd9f9e79eaefa94ca5c45
7
- data.tar.gz: 9e70a02a0acdf5eca299ad5f4b201b3fb44a98977d972d08db4167938f24793b732a55f25d6ea3585c8c63d92b014bc108083cce1aef1659b65c628f3ab0b27f
6
+ metadata.gz: 81f2eb5e04d1cf082cf3eaadd5d609d4abcdef1841d1dde9b2eee68c0b8e570b7c0c72dd0a2a05c7213b0c611a267a2b6fbca044d2a86711765aa91f8e2ea763
7
+ data.tar.gz: cf7864860eb1fab370902861200b23150d08b1d135f614896a9c32fe20ea9e1edc7070563325c4a102a4af64c986c308892e13aefe9ad2590e4ac75d17f30a22
data/UPGRADING.md CHANGED
@@ -12,6 +12,8 @@ end
12
12
 
13
13
  Remove each legacy setting making sure your app functions as expected.
14
14
 
15
+ Replace `Money.parse` with `Money::Parser::Fuzzy.parse`.
16
+
15
17
  ### Legacy support
16
18
 
17
19
  #### legacy_default_currency!
data/lib/money/config.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  class Money
4
4
  class Config
5
- attr_accessor :parser, :default_currency, :legacy_json_format, :legacy_deprecations
5
+ attr_accessor :default_currency, :legacy_json_format, :legacy_deprecations
6
6
 
7
7
  def legacy_default_currency!
8
8
  @default_currency ||= Money::NULL_CURRENCY
@@ -17,7 +17,6 @@ class Money
17
17
  end
18
18
 
19
19
  def initialize
20
- @parser = MoneyParser
21
20
  @default_currency = nil
22
21
  @legacy_json_format = false
23
22
  @legacy_deprecations = false
@@ -14,6 +14,6 @@ end
14
14
  # '100.37'.to_money => #<Money @cents=10037>
15
15
  class String
16
16
  def to_money(currency = nil)
17
- Money.parse(self, currency)
17
+ Money::Parser::Fuzzy.parse(self, currency)
18
18
  end
19
19
  end
data/lib/money/money.rb CHANGED
@@ -13,7 +13,7 @@ class Money
13
13
  class << self
14
14
  extend Forwardable
15
15
  attr_accessor :config
16
- def_delegators :@config, :parser, :parser=, :default_currency, :default_currency=
16
+ def_delegators :@config, :default_currency, :default_currency=
17
17
 
18
18
  def configure
19
19
  self.config ||= Config.new
@@ -33,10 +33,6 @@ class Money
33
33
  end
34
34
  alias_method :from_amount, :new
35
35
 
36
- def parse(*args, **kwargs)
37
- parser.parse(*args, **kwargs)
38
- end
39
-
40
36
  def from_subunits(subunits, currency_iso, format: :iso4217)
41
37
  currency = Helpers.value_to_currency(currency_iso)
42
38
 
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ class Money
3
+ module Parser
4
+ class Accounting < Fuzzy
5
+ def parse(input, currency = nil, **options)
6
+ # set () to mean negativity. ignore $
7
+ super(input.gsub(/\(\$?(.*?)\)/, '-\1'), currency, **options)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+ class Money
3
+ module Parser
4
+ class Fuzzy
5
+ class MoneyFormatError < ArgumentError; end
6
+
7
+ MARKS = %w[. , · ’ ˙ '] + [' ']
8
+
9
+ ESCAPED_MARKS = Regexp.escape(MARKS.join)
10
+ ESCAPED_NON_SPACE_MARKS = Regexp.escape((MARKS - [' ']).join)
11
+ ESCAPED_NON_DOT_MARKS = Regexp.escape((MARKS - ['.']).join)
12
+ ESCAPED_NON_COMMA_MARKS = Regexp.escape((MARKS - [',']).join)
13
+
14
+ NUMERIC_REGEX = /(
15
+ [\+\-]?
16
+ [\d#{ESCAPED_NON_SPACE_MARKS}][\d#{ESCAPED_MARKS}]*
17
+ )/ix
18
+
19
+ # 1,234,567.89
20
+ DOT_DECIMAL_REGEX = /\A
21
+ [\+\-]?
22
+ (?:
23
+ (?:\d+)
24
+ (?:[#{ESCAPED_NON_DOT_MARKS}]\d{3})+
25
+ (?:\.\d{2,})?
26
+ )
27
+ \z/ix
28
+
29
+ # 1.234.567,89
30
+ COMMA_DECIMAL_REGEX = /\A
31
+ [\+\-]?
32
+ (?:
33
+ (?:\d+)
34
+ (?:[#{ESCAPED_NON_COMMA_MARKS}]\d{3})+
35
+ (?:\,\d{2,})?
36
+ )
37
+ \z/ix
38
+
39
+ # 12,34,567.89
40
+ INDIAN_NUMERIC_REGEX = /\A
41
+ [\+\-]?
42
+ (?:
43
+ (?:\d+)
44
+ (?:\,\d{2})+
45
+ (?:\,\d{3})
46
+ (?:\.\d{2})?
47
+ )
48
+ \z/ix
49
+
50
+ # 1,1123,4567.89
51
+ CHINESE_NUMERIC_REGEX = /\A
52
+ [\+\-]?
53
+ (?:
54
+ (?:\d+)
55
+ (?:\,\d{4})+
56
+ (?:\.\d{2})?
57
+ )
58
+ \z/ix
59
+
60
+ def self.parse(input, currency = nil, **options)
61
+ new.parse(input, currency, **options)
62
+ end
63
+
64
+ # Parses an input string and attempts to find the decimal separator based on certain heuristics, like the amount
65
+ # decimals for the fractional part a currency has or the incorrect notion a currency has a defined decimal
66
+ # separator (this is a property of the locale). While these heuristics can lead to the expected result for some
67
+ # cases, the other cases can lead to surprising results such as parsed amounts being 1000x larger than intended.
68
+ # @deprecated Use {LocaleAware.parse} or {Simple.parse} instead.
69
+ # @param input [String]
70
+ # @param currency [String, Money::Currency, nil]
71
+ # @param strict [Boolean]
72
+ # @return [Money]
73
+ # @raise [MoneyFormatError]
74
+ def parse(input, currency = nil, strict: false)
75
+ currency = Money::Helpers.value_to_currency(currency)
76
+ amount = extract_amount_from_string(input, currency, strict)
77
+ Money.new(amount, currency)
78
+ end
79
+
80
+ private
81
+
82
+ def extract_amount_from_string(input, currency, strict)
83
+ unless input.is_a?(String)
84
+ return input
85
+ end
86
+
87
+ if input.strip.empty?
88
+ return '0'
89
+ end
90
+
91
+ number = input.scan(NUMERIC_REGEX).flatten.first
92
+ number = number.to_s.strip
93
+
94
+ if number.empty?
95
+ if Money.config.legacy_deprecations && !strict
96
+ Money.deprecate("invalid money strings will raise in the next major release \"#{input}\"")
97
+ return '0'
98
+ else
99
+ raise MoneyFormatError, "invalid money string: #{input}"
100
+ end
101
+ end
102
+
103
+ marks = number.scan(/[#{ESCAPED_MARKS}]/).flatten
104
+ if marks.empty?
105
+ return number
106
+ end
107
+
108
+ if marks.size == 1
109
+ return normalize_number(number, marks, currency)
110
+ end
111
+
112
+ # remove end of string mark
113
+ number.sub!(/[#{ESCAPED_MARKS}]\z/, '')
114
+
115
+ if amount = number[DOT_DECIMAL_REGEX] || number[INDIAN_NUMERIC_REGEX] || number[CHINESE_NUMERIC_REGEX]
116
+ return amount.tr(ESCAPED_NON_DOT_MARKS, '')
117
+ end
118
+
119
+ if amount = number[COMMA_DECIMAL_REGEX]
120
+ return amount.tr(ESCAPED_NON_COMMA_MARKS, '').sub(',', '.')
121
+ end
122
+
123
+ if Money.config.legacy_deprecations && !strict
124
+ Money.deprecate("invalid money strings will raise in the next major release \"#{input}\"")
125
+ else
126
+ raise MoneyFormatError, "invalid money string: #{input}"
127
+ end
128
+
129
+ normalize_number(number, marks, currency)
130
+ end
131
+
132
+ def normalize_number(number, marks, currency)
133
+ digits = number.rpartition(marks.last)
134
+ digits.first.tr!(ESCAPED_MARKS, '')
135
+
136
+ if last_digits_decimals?(digits, marks, currency)
137
+ "#{digits.first}.#{digits.last}"
138
+ else
139
+ "#{digits.first}#{digits.last}"
140
+ end
141
+ end
142
+
143
+ def last_digits_decimals?(digits, marks, currency)
144
+ # Grouping marks are always different from decimal marks
145
+ # Example: 1,234,456
146
+ *other_marks, last_mark = marks
147
+ other_marks.uniq!
148
+ if other_marks.size == 1
149
+ return other_marks.first != last_mark
150
+ end
151
+
152
+ # Thousands always have more than 2 digits
153
+ # Example: 1,23 must be 1 dollar and 23 cents
154
+ if digits.last.size < 3
155
+ return !digits.last.empty?
156
+ end
157
+
158
+ # 0 before the final mark indicates last digits are decimals
159
+ # Example: 0,23
160
+ if digits.first.to_i.zero?
161
+ return true
162
+ end
163
+
164
+ # legacy support for 1.000 USD
165
+ if digits.last.size == 3 && digits.first.size <= 3 && currency.minor_units < 3
166
+ return false
167
+ end
168
+
169
+ # The last mark matches the one used by the provided currency to delimiter decimals
170
+ currency.decimal_mark == last_mark
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ class Money
3
+ module Parser
4
+ class LocaleAware
5
+ @decimal_separator_resolver = nil
6
+
7
+ class << self
8
+ # The +Proc+ called to get the current locale decimal separator. In Rails apps this defaults to the same lookup
9
+ # ActionView's +number_to_currency+ helper will use to format the monetary amount for display.
10
+ def decimal_separator_resolver
11
+ @decimal_separator_resolver
12
+ end
13
+
14
+ # Set the default +Proc+ to determine the current locale decimal separator.
15
+ #
16
+ # @example
17
+ # Money::Parser::LocaleAware.decimal_separator_resolver =
18
+ # ->() { MyFormattingLibrary.current_locale.decimal.separator }
19
+ def decimal_separator_resolver=(proc)
20
+ @decimal_separator_resolver = proc
21
+ end
22
+
23
+ # Parses an input string, normalizing some non-ASCII characters to their equivalent ASCII, then discarding any
24
+ # character that is not a digit, hyphen-minus or the decimal separator. To prevent user confusion, make sure
25
+ # that formatted Money strings can be parsed back into equivalent Money objects.
26
+ #
27
+ # @param input [String]
28
+ # @param currency [String, Money::Currency]
29
+ # @param strict [Boolean]
30
+ # @param decimal_separator [String]
31
+ # @return [Money, nil]
32
+ def parse(input, currency, strict: false, decimal_separator: decimal_separator_resolver&.call)
33
+ raise ArgumentError, "decimal separator cannot be nil" unless decimal_separator
34
+
35
+ currency = Money::Helpers.value_to_currency(currency)
36
+ return unless currency
37
+
38
+ normalized_input = input
39
+ .tr('-0-9.,、、', '-0-9.,,,')
40
+ .gsub(/[^\d\-#{Regexp.escape(decimal_separator)}]/, '')
41
+ .gsub(decimal_separator, '.')
42
+ amount = BigDecimal(normalized_input, exception: false)
43
+ if amount
44
+ Money.new(amount, currency)
45
+ elsif strict
46
+ raise ArgumentError, "unable to parse input=\"#{input}\" currency=\"#{currency}\""
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ class Money
3
+ module Parser
4
+ class Simple
5
+ SIGNED_DECIMAL_MATCHER = /\A-?\d*(?:\.\d*)?\z/.freeze
6
+
7
+ class << self
8
+ # Parses an input string using BigDecimal, it always expects a dot character as a decimal separator and
9
+ # generally does not accept other characters other than minus-hyphen and digits. It is useful for APIs, interop
10
+ # with other languages and other use cases where you expect well-formatted input and do not need to take user
11
+ # locale into consideration.
12
+ # @param input [String]
13
+ # @param currency [String, Money::Currency]
14
+ # @param strict [Boolean]
15
+ # @return [Money, nil]
16
+ def parse(input, currency, strict: false)
17
+ currency = Money::Helpers.value_to_currency(currency)
18
+ return unless currency
19
+
20
+ coerced = input.to_s
21
+ if SIGNED_DECIMAL_MATCHER.match?(coerced) && (amount = BigDecimal(coerced, exception: false))
22
+ Money.new(amount, currency)
23
+ elsif strict
24
+ raise ArgumentError, "unable to parse input=\"#{input}\" currency=\"#{currency}\""
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
data/lib/money/railtie.rb CHANGED
@@ -8,5 +8,12 @@ class Money
8
8
  ActiveJob::Serializers.add_serializers ::Money::Rails::JobArgumentSerializer
9
9
  end
10
10
  end
11
+
12
+ initializer "shopify-money.setup_locale_aware_parser" do
13
+ ActiveSupport.on_load(:action_view) do
14
+ Money::Parser::LocaleAware.decimal_separator_resolver =
15
+ -> { ::I18n.translate("number.currency.format.separator") }
16
+ end
17
+ end
11
18
  end
12
19
  end
data/lib/money/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  class Money
3
- VERSION = "1.0.1.pre"
3
+ VERSION = "1.0.2.pre"
4
4
  end
data/lib/money.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative 'money/version'
3
- require_relative 'money/money_parser'
3
+ require_relative 'money/parser/fuzzy'
4
4
  require_relative 'money/helpers'
5
5
  require_relative 'money/currency'
6
6
  require_relative 'money/null_currency'
@@ -9,7 +9,9 @@ require_relative 'money/config'
9
9
  require_relative 'money/money'
10
10
  require_relative 'money/errors'
11
11
  require_relative 'money/deprecations'
12
- require_relative 'money/accounting_money_parser'
12
+ require_relative 'money/parser/accounting'
13
+ require_relative 'money/parser/locale_aware'
14
+ require_relative 'money/parser/simple'
13
15
  require_relative 'money/core_extensions'
14
16
  require_relative 'money_column' if defined?(ActiveRecord)
15
17
  require_relative 'money/railtie' if defined?(Rails::Railtie)
data/spec/config_spec.rb CHANGED
@@ -32,18 +32,6 @@ RSpec.describe "Money::Config" do
32
32
  end
33
33
  end
34
34
 
35
- describe 'parser' do
36
- it 'defaults to MoneyParser' do
37
- expect(Money::Config.new.parser).to eq(MoneyParser)
38
- end
39
-
40
- it 'can be set to a new parser' do
41
- configure(parser: AccountingMoneyParser) do
42
- expect(Money.config.parser).to eq(AccountingMoneyParser)
43
- end
44
- end
45
- end
46
-
47
35
  describe 'default_currency' do
48
36
  it 'defaults to nil' do
49
37
  configure do
data/spec/money_spec.rb CHANGED
@@ -794,22 +794,6 @@ RSpec.describe "Money" do
794
794
  end
795
795
  end
796
796
 
797
- describe "parser dependency injection" do
798
- around(:each) { |test| configure(parser: AccountingMoneyParser, default_currency: 'CAD') { test.run }}
799
-
800
- it "keeps AccountingMoneyParser class on new money objects" do
801
- expect(Money.new.class.parser).to eq(AccountingMoneyParser)
802
- end
803
-
804
- it "supports parenthesis from AccountingMoneyParser" do
805
- expect(Money.parse("($5.00)")).to eq(Money.new(-5))
806
- end
807
-
808
- it "supports parenthesis from AccountingMoneyParser for .to_money" do
809
- expect("($5.00)".to_money).to eq(Money.new(-5))
810
- end
811
- end
812
-
813
797
  describe "round" do
814
798
 
815
799
  it "rounds to 0 decimal places by default" do
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
  require 'spec_helper'
3
3
 
4
- RSpec.describe AccountingMoneyParser do
5
- describe "parsing of amounts with period decimal separator" do
6
- before(:each) do
7
- @parser = AccountingMoneyParser.new
8
- end
4
+ RSpec.describe Money::Parser::Accounting do
5
+ before(:each) do
6
+ @parser = described_class
7
+ end
9
8
 
9
+ describe "parsing of amounts with period decimal separator" do
10
10
  it "parses parenthesis as a negative amount eg (99.00)" do
11
11
  expect(@parser.parse("(99.00)")).to eq(Money.new(-99.00))
12
12
  end
@@ -104,10 +104,6 @@ RSpec.describe AccountingMoneyParser do
104
104
  end
105
105
 
106
106
  describe "parsing of amounts with comma decimal separator" do
107
- before(:each) do
108
- @parser = AccountingMoneyParser.new
109
- end
110
-
111
107
  it "parses dollar amount $1,00 with leading $" do
112
108
  expect(@parser.parse("$1,00")).to eq(Money.new(1.00))
113
109
  end
@@ -156,10 +152,6 @@ RSpec.describe AccountingMoneyParser do
156
152
  end
157
153
 
158
154
  describe "parsing of decimal cents amounts from 0 to 10" do
159
- before(:each) do
160
- @parser = AccountingMoneyParser.new
161
- end
162
-
163
155
  it "parses 50.0" do
164
156
  expect(@parser.parse("50.0")).to eq(Money.new(50.00))
165
157
  end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
  require 'spec_helper'
3
3
 
4
- RSpec.describe MoneyParser do
4
+ RSpec.describe Money::Parser::Fuzzy do
5
5
  before(:each) do
6
- @parser = MoneyParser
6
+ @parser = described_class
7
7
  end
8
8
 
9
9
  describe "parsing of amounts with period decimal separator" do
@@ -20,8 +20,8 @@ RSpec.describe MoneyParser do
20
20
  end
21
21
 
22
22
  it "parses raise with an invalid string and strict option" do
23
- expect { @parser.parse("no money", strict: true) }.to raise_error(MoneyParser::MoneyFormatError)
24
- expect { @parser.parse("1..1", strict: true) }.to raise_error(MoneyParser::MoneyFormatError)
23
+ expect { @parser.parse("no money", strict: true) }.to raise_error(described_class::MoneyFormatError)
24
+ expect { @parser.parse("1..1", strict: true) }.to raise_error(described_class::MoneyFormatError)
25
25
  end
26
26
 
27
27
  it "parses raise with an invalid when a currency is missing" do
@@ -159,7 +159,7 @@ RSpec.describe MoneyParser do
159
159
  end
160
160
 
161
161
  it "parses raises with multiple inconsistent thousands delimiters and strict option" do
162
- expect { @parser.parse("1.1.11.111", strict: true) }.to raise_error(MoneyParser::MoneyFormatError)
162
+ expect { @parser.parse("1.1.11.111", strict: true) }.to raise_error(described_class::MoneyFormatError)
163
163
  end
164
164
  end
165
165
 
@@ -233,7 +233,7 @@ RSpec.describe MoneyParser do
233
233
  end
234
234
 
235
235
  it "parses raises with multiple inconsistent thousands delimiters and strict option" do
236
- expect { @parser.parse("1,1,11,111", strict: true) }.to raise_error(MoneyParser::MoneyFormatError)
236
+ expect { @parser.parse("1,1,11,111", strict: true) }.to raise_error(described_class::MoneyFormatError)
237
237
  end
238
238
  end
239
239
 
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+
4
+ RSpec.describe Money::Parser::LocaleAware do
5
+ context "parsing amounts with period decimal separator" do
6
+ around(:example) do |example|
7
+ with_decimal_separator(".") { example.run }
8
+ end
9
+
10
+ it "parses an empty string to nil" do
11
+ expect(described_class.parse("", "CAD")).to be_nil
12
+ end
13
+
14
+ it "parses an invalid string when not strict" do
15
+ expect(described_class.parse("no money", "CAD")).to be_nil
16
+ expect(described_class.parse("1..", "CAD")).to be_nil
17
+ end
18
+
19
+ it "raises with an invalid string and strict option" do
20
+ expect { described_class.parse("no money", "CAD", strict: true) }.to raise_error(ArgumentError)
21
+ expect { described_class.parse("1..1", "CAD", strict: true) }.to raise_error(ArgumentError)
22
+ end
23
+
24
+ it "parses an integer string amount" do
25
+ expect(described_class.parse("1", "CAD")).to eq(Money.new(1.00, "CAD"))
26
+ expect(described_class.parse("-1", "CAD")).to eq(Money.new(-1.00, "CAD"))
27
+ end
28
+
29
+ it "parses a float string amount" do
30
+ expect(described_class.parse("1.37", "CAD")).to eq(Money.new(1.37, "CAD"))
31
+ expect(described_class.parse("-1.37", "CAD")).to eq(Money.new(-1.37, "CAD"))
32
+ end
33
+
34
+ it "parses a float string with 3 decimals" do
35
+ expect(described_class.parse("1.378", "JOD")).to eq(Money.new(1.378, "JOD"))
36
+ expect(described_class.parse("-1.378", "JOD")).to eq(Money.new(-1.378, "JOD"))
37
+ expect(described_class.parse("123.456", "USD")).to eq(Money.new(123.46, "USD"))
38
+ end
39
+
40
+ it "parses a float string amount with a leading random character" do
41
+ expect(described_class.parse("$1.37", "CAD")).to eq(Money.new(1.37, "CAD"))
42
+ expect(described_class.parse(",1.37", "CAD")).to eq(Money.new(1.37, "CAD"))
43
+ expect(described_class.parse("€1.37", "CAD")).to eq(Money.new(1.37, "CAD"))
44
+ end
45
+
46
+ it "parses a float string amount with a trailing random character" do
47
+ expect(described_class.parse("1.37$", "CAD")).to eq(Money.new(1.37, "CAD"))
48
+ expect(described_class.parse("1.37,", "CAD")).to eq(Money.new(1.37, "CAD"))
49
+ expect(described_class.parse("1.37€", "CAD")).to eq(Money.new(1.37, "CAD"))
50
+ end
51
+
52
+ it "parses an amount with one or more thousands separators" do
53
+ expect(described_class.parse("100,000", "CAD")).to eq(Money.new(100_000.00, "CAD"))
54
+ expect(described_class.parse("-100,000", "CAD")).to eq(Money.new(-100_000.00, "CAD"))
55
+ expect(described_class.parse("100,000.01", "CAD")).to eq(Money.new(100_000.01, "CAD"))
56
+ expect(described_class.parse("1,00,000.01", "CAD")).to eq(Money.new(100_000.01, "CAD"))
57
+ expect(described_class.parse("1,00,000.001", "JOD")).to eq(Money.new(100_000.001, "JOD"))
58
+ end
59
+ end
60
+
61
+ context "parsing amounts with comma decimal separator" do
62
+ around(:example) do |example|
63
+ with_decimal_separator(",") { example.run }
64
+ end
65
+
66
+ it "parses an empty string to nil" do
67
+ expect(described_class.parse("", "CAD")).to be_nil
68
+ end
69
+
70
+ it "parses an invalid string when not strict" do
71
+ expect(described_class.parse("no money", "CAD")).to be_nil
72
+ expect(described_class.parse("1,,", "CAD")).to be_nil
73
+ end
74
+
75
+ it "raises with an invalid string and strict option" do
76
+ expect { described_class.parse("no money", "CAD", strict: true) }.to raise_error(ArgumentError)
77
+ expect { described_class.parse("1,,1", "CAD", strict: true) }.to raise_error(ArgumentError)
78
+ end
79
+
80
+ it "parses an integer string amount" do
81
+ expect(described_class.parse("1", "CAD")).to eq(Money.new(1.00, "CAD"))
82
+ expect(described_class.parse("-1", "CAD")).to eq(Money.new(-1.00, "CAD"))
83
+ end
84
+
85
+ it "parses a float string amount" do
86
+ expect(described_class.parse("1,37", "CAD")).to eq(Money.new(1.37, "CAD"))
87
+ expect(described_class.parse("-1,37", "CAD")).to eq(Money.new(-1.37, "CAD"))
88
+ end
89
+
90
+ it "parses a float string with 3 decimals" do
91
+ expect(described_class.parse("1,378", "JOD")).to eq(Money.new(1.378, "JOD"))
92
+ expect(described_class.parse("-1,378", "JOD")).to eq(Money.new(-1.378, "JOD"))
93
+ end
94
+
95
+ it "parses a float string amount with a leading random character" do
96
+ expect(described_class.parse("$1,37", "CAD")).to eq(Money.new(1.37, "CAD"))
97
+ expect(described_class.parse(".1,37", "CAD")).to eq(Money.new(1.37, "CAD"))
98
+ expect(described_class.parse("€1,37", "CAD")).to eq(Money.new(1.37, "CAD"))
99
+ end
100
+
101
+ it "parses a float string amount with a trailing random character" do
102
+ expect(described_class.parse("1,37$", "CAD")).to eq(Money.new(1.37, "CAD"))
103
+ expect(described_class.parse("1,37.", "CAD")).to eq(Money.new(1.37, "CAD"))
104
+ expect(described_class.parse("1,37€", "CAD")).to eq(Money.new(1.37, "CAD"))
105
+ end
106
+
107
+ it "parses an amount with one or more thousands separators" do
108
+ expect(described_class.parse("100.000", "CAD")).to eq(Money.new(100_000.00, "CAD"))
109
+ expect(described_class.parse("-100.000", "CAD")).to eq(Money.new(-100_000.00, "CAD"))
110
+ expect(described_class.parse("100.000,01", "CAD")).to eq(Money.new(100_000.01, "CAD"))
111
+ expect(described_class.parse("1.00.000,01", "CAD")).to eq(Money.new(100_000.01, "CAD"))
112
+ expect(described_class.parse("1.00.000,001", "JOD")).to eq(Money.new(100_000.001, "JOD"))
113
+ end
114
+ end
115
+
116
+ context "parsing amounts with fullwidth characters" do
117
+ around(:example) do |example|
118
+ with_decimal_separator(".") { example.run }
119
+ end
120
+
121
+ it "parses an empty string to nil" do
122
+ expect(described_class.parse("", "CAD")).to be_nil
123
+ end
124
+
125
+ it "parses an invalid string when not strict" do
126
+ expect(described_class.parse("no money", "CAD")).to be_nil
127
+ expect(described_class.parse("1..", "CAD")).to be_nil
128
+ end
129
+
130
+ it "raises with an invalid string and strict option" do
131
+ expect { described_class.parse("no money", "CAD", strict: true) }.to raise_error(ArgumentError)
132
+ expect { described_class.parse("1..1", "CAD", strict: true) }.to raise_error(ArgumentError)
133
+ end
134
+
135
+ it "parses an integer string amount" do
136
+ expect(described_class.parse("1", "CAD")).to eq(Money.new(1.00, "CAD"))
137
+ expect(described_class.parse("-1", "CAD")).to eq(Money.new(-1.00, "CAD"))
138
+ end
139
+
140
+ it "parses a float string amount" do
141
+ expect(described_class.parse("1.37", "CAD")).to eq(Money.new(1.37, "CAD"))
142
+ expect(described_class.parse("-1.37", "CAD")).to eq(Money.new(-1.37, "CAD"))
143
+ end
144
+
145
+ it "parses a float string with 3 decimals" do
146
+ expect(described_class.parse("1.378", "JOD")).to eq(Money.new(1.378, "JOD"))
147
+ expect(described_class.parse("-1.378", "JOD")).to eq(Money.new(-1.378, "JOD"))
148
+ end
149
+
150
+ it "parses a float string amount with a leading random character" do
151
+ expect(described_class.parse("$1.37", "CAD")).to eq(Money.new(1.37, "CAD"))
152
+ expect(described_class.parse(",1.37", "CAD")).to eq(Money.new(1.37, "CAD"))
153
+ expect(described_class.parse("€1.37", "CAD")).to eq(Money.new(1.37, "CAD"))
154
+ end
155
+
156
+ it "parses a float string amount with a trailing random character" do
157
+ expect(described_class.parse("1.37$", "CAD")).to eq(Money.new(1.37, "CAD"))
158
+ expect(described_class.parse("1.37,", "CAD")).to eq(Money.new(1.37, "CAD"))
159
+ expect(described_class.parse("1.37€", "CAD")).to eq(Money.new(1.37, "CAD"))
160
+ end
161
+
162
+ it "parses an amount with one or more thousands separators" do
163
+ expect(described_class.parse("100,000", "CAD")).to eq(Money.new(100_000.00, "CAD"))
164
+ expect(described_class.parse("-100,000", "CAD")).to eq(Money.new(-100_000.00, "CAD"))
165
+ expect(described_class.parse("100,000.01", "CAD")).to eq(Money.new(100_000.01, "CAD"))
166
+ expect(described_class.parse("1,00,000.01", "CAD")).to eq(Money.new(100_000.01, "CAD"))
167
+ expect(described_class.parse("1,00,000.001", "JOD")).to eq(Money.new(100_000.001, "JOD"))
168
+ expect(described_class.parse("100,000、000", "JPY")).to eq(Money.new(100_000_000, "JPY"))
169
+ end
170
+ end
171
+
172
+ context "bad decimal_separator_proc" do
173
+ context "raises when called" do
174
+ around(:example) do |example|
175
+ with_decimal_separator_proc(->() { raise NoMethodError }) { example.run }
176
+ end
177
+
178
+ it "raises the original error" do
179
+ expect { described_class.parse("1", "CAD") }.to raise_error(NoMethodError)
180
+ end
181
+ end
182
+
183
+ context "returning nil" do
184
+ around(:example) do |example|
185
+ with_decimal_separator(nil) { example.run }
186
+ end
187
+
188
+ it "raises the original error" do
189
+ expect { described_class.parse("1", "CAD") }.to raise_error(ArgumentError)
190
+ end
191
+ end
192
+ end
193
+
194
+ private
195
+
196
+ def with_decimal_separator(character)
197
+ with_decimal_separator_proc(->() { character }) do
198
+ yield
199
+ end
200
+ end
201
+
202
+ def with_decimal_separator_proc(proc)
203
+ old = described_class.decimal_separator_resolver
204
+ described_class.decimal_separator_resolver = proc
205
+ yield
206
+ described_class.decimal_separator_resolver = old
207
+ end
208
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+
4
+ RSpec.describe Money::Parser::Simple do
5
+ context "parsing amounts with period decimal separator" do
6
+ it "parses an empty string to nil" do
7
+ expect(described_class.parse("", "CAD")).to be_nil
8
+ end
9
+
10
+ it "parses an invalid string when not strict" do
11
+ expect(described_class.parse("no money", "CAD")).to be_nil
12
+ expect(described_class.parse("1..", "CAD")).to be_nil
13
+ expect(described_class.parse("10.", "CAD")).to be_nil
14
+ expect(described_class.parse("10.1E2", "CAD")).to be_nil
15
+ end
16
+
17
+ it "raises with an invalid string and strict option" do
18
+ expect { described_class.parse("no money", "CAD", strict: true) }.to raise_error(ArgumentError)
19
+ expect { described_class.parse("1..1", "CAD", strict: true) }.to raise_error(ArgumentError)
20
+ expect { described_class.parse("10.", "CAD", strict: true) }.to raise_error(ArgumentError)
21
+ expect { described_class.parse("10.1E2", "CAD", strict: true) }.to raise_error(ArgumentError)
22
+ end
23
+
24
+ it "parses an integer string amount" do
25
+ expect(described_class.parse("1", "CAD")).to eq(Money.new(1.00, "CAD"))
26
+ expect(described_class.parse("-1", "CAD")).to eq(Money.new(-1.00, "CAD"))
27
+ end
28
+
29
+ it "parses a float string amount" do
30
+ expect(described_class.parse("1.37", "CAD")).to eq(Money.new(1.37, "CAD"))
31
+ expect(described_class.parse("-1.37", "CAD")).to eq(Money.new(-1.37, "CAD"))
32
+ end
33
+
34
+ it "parses a float string with 3 decimals" do
35
+ expect(described_class.parse("1.378", "JOD")).to eq(Money.new(1.378, "JOD"))
36
+ expect(described_class.parse("-1.378", "JOD")).to eq(Money.new(-1.378, "JOD"))
37
+ expect(described_class.parse("123.456", "USD")).to eq(Money.new(123.46, "USD"))
38
+ end
39
+
40
+ it "does not parse a float string amount with a leading random character" do
41
+ expect(described_class.parse("$1.37", "CAD")).to be_nil
42
+ expect(described_class.parse(",1.37", "CAD")).to be_nil
43
+ expect(described_class.parse("€1.37", "CAD")).to be_nil
44
+ expect(described_class.parse(" 1.37", "CAD")).to be_nil
45
+ end
46
+
47
+ it "does not parse a float string amount with a trailing random character" do
48
+ expect(described_class.parse("1.37$", "CAD")).to be_nil
49
+ expect(described_class.parse("1.37,", "CAD")).to be_nil
50
+ expect(described_class.parse("1.37€", "CAD")).to be_nil
51
+ expect(described_class.parse("1.37 ", "CAD")).to be_nil
52
+ end
53
+
54
+ it "does not parse an amount with one or more thousands separators" do
55
+ expect(described_class.parse("100,000", "CAD")).to be_nil
56
+ expect(described_class.parse("-100,000", "CAD")).to be_nil
57
+ expect(described_class.parse("100,000.01", "CAD")).to be_nil
58
+ expect(described_class.parse("1,00,000.01", "CAD")).to be_nil
59
+ expect(described_class.parse("1,00,000.001", "JOD")).to be_nil
60
+ end
61
+ end
62
+ end
data/spec/spec_helper.rb CHANGED
@@ -3,6 +3,7 @@ require 'simplecov'
3
3
  SimpleCov.minimum_coverage 100
4
4
  SimpleCov.start do
5
5
  add_filter "/spec/"
6
+ add_filter "/lib/money/railtie"
6
7
  end
7
8
 
8
9
  $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
@@ -70,11 +71,10 @@ RSpec::Matchers.define :quack_like do
70
71
  end
71
72
 
72
73
 
73
- def configure(default_currency: nil, legacy_json_format: nil, legacy_deprecations: nil, legacy_default_currency: nil, parser: nil)
74
+ def configure(default_currency: nil, legacy_json_format: nil, legacy_deprecations: nil, legacy_default_currency: nil)
74
75
  old_config = Money.config
75
76
  Money.config = Money::Config.new.tap do |config|
76
77
  config.default_currency = default_currency if default_currency
77
- config.parser = parser if parser
78
78
  config.legacy_json_format! if legacy_json_format
79
79
  config.legacy_deprecations! if legacy_deprecations
80
80
  config.legacy_default_currency! if legacy_default_currency
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shopify-money
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1.pre
4
+ version: 1.0.2.pre
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify Inc
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-04-15 00:00:00.000000000 Z
11
+ date: 2022-04-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -117,7 +117,6 @@ files:
117
117
  - config/currency_non_iso.yml
118
118
  - dev.yml
119
119
  - lib/money.rb
120
- - lib/money/accounting_money_parser.rb
121
120
  - lib/money/allocator.rb
122
121
  - lib/money/config.rb
123
122
  - lib/money/core_extensions.rb
@@ -127,8 +126,11 @@ files:
127
126
  - lib/money/errors.rb
128
127
  - lib/money/helpers.rb
129
128
  - lib/money/money.rb
130
- - lib/money/money_parser.rb
131
129
  - lib/money/null_currency.rb
130
+ - lib/money/parser/accounting.rb
131
+ - lib/money/parser/fuzzy.rb
132
+ - lib/money/parser/locale_aware.rb
133
+ - lib/money/parser/simple.rb
132
134
  - lib/money/rails/job_argument_serializer.rb
133
135
  - lib/money/railtie.rb
134
136
  - lib/money/version.rb
@@ -141,7 +143,6 @@ files:
141
143
  - lib/rubocop/cop/money/zero_money.rb
142
144
  - lib/shopify-money.rb
143
145
  - money.gemspec
144
- - spec/accounting_money_parser_spec.rb
145
146
  - spec/allocator_spec.rb
146
147
  - spec/config_spec.rb
147
148
  - spec/core_extensions_spec.rb
@@ -149,9 +150,12 @@ files:
149
150
  - spec/currency_spec.rb
150
151
  - spec/helpers_spec.rb
151
152
  - spec/money_column_spec.rb
152
- - spec/money_parser_spec.rb
153
153
  - spec/money_spec.rb
154
154
  - spec/null_currency_spec.rb
155
+ - spec/parser/accounting_spec.rb
156
+ - spec/parser/fuzzy_spec.rb
157
+ - spec/parser/locale_aware_spec.rb
158
+ - spec/parser/simple_spec.rb
155
159
  - spec/rails/job_argument_serializer_spec.rb
156
160
  - spec/rails_spec_helper.rb
157
161
  - spec/rubocop/cop/money/missing_currency_spec.rb
@@ -184,7 +188,6 @@ signing_key:
184
188
  specification_version: 4
185
189
  summary: Shopify's money gem
186
190
  test_files:
187
- - spec/accounting_money_parser_spec.rb
188
191
  - spec/allocator_spec.rb
189
192
  - spec/config_spec.rb
190
193
  - spec/core_extensions_spec.rb
@@ -192,9 +195,12 @@ test_files:
192
195
  - spec/currency_spec.rb
193
196
  - spec/helpers_spec.rb
194
197
  - spec/money_column_spec.rb
195
- - spec/money_parser_spec.rb
196
198
  - spec/money_spec.rb
197
199
  - spec/null_currency_spec.rb
200
+ - spec/parser/accounting_spec.rb
201
+ - spec/parser/fuzzy_spec.rb
202
+ - spec/parser/locale_aware_spec.rb
203
+ - spec/parser/simple_spec.rb
198
204
  - spec/rails/job_argument_serializer_spec.rb
199
205
  - spec/rails_spec_helper.rb
200
206
  - spec/rubocop/cop/money/missing_currency_spec.rb
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class AccountingMoneyParser < MoneyParser
4
- def parse(input, currency = nil, **options)
5
- # set () to mean negativity. ignore $
6
- super(input.gsub(/\(\$?(.*?)\)/, '-\1'), currency, **options)
7
- end
8
- end
@@ -1,162 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Parse an amount from a string
4
- class MoneyParser
5
- class MoneyFormatError < ArgumentError; end
6
-
7
- MARKS = %w[. , · ’ ˙ '] + [' ']
8
-
9
- ESCAPED_MARKS = Regexp.escape(MARKS.join)
10
- ESCAPED_NON_SPACE_MARKS = Regexp.escape((MARKS - [' ']).join)
11
- ESCAPED_NON_DOT_MARKS = Regexp.escape((MARKS - ['.']).join)
12
- ESCAPED_NON_COMMA_MARKS = Regexp.escape((MARKS - [',']).join)
13
-
14
- NUMERIC_REGEX = /(
15
- [\+\-]?
16
- [\d#{ESCAPED_NON_SPACE_MARKS}][\d#{ESCAPED_MARKS}]*
17
- )/ix
18
-
19
- # 1,234,567.89
20
- DOT_DECIMAL_REGEX = /\A
21
- [\+\-]?
22
- (?:
23
- (?:\d+)
24
- (?:[#{ESCAPED_NON_DOT_MARKS}]\d{3})+
25
- (?:\.\d{2,})?
26
- )
27
- \z/ix
28
-
29
- # 1.234.567,89
30
- COMMA_DECIMAL_REGEX = /\A
31
- [\+\-]?
32
- (?:
33
- (?:\d+)
34
- (?:[#{ESCAPED_NON_COMMA_MARKS}]\d{3})+
35
- (?:\,\d{2,})?
36
- )
37
- \z/ix
38
-
39
- # 12,34,567.89
40
- INDIAN_NUMERIC_REGEX = /\A
41
- [\+\-]?
42
- (?:
43
- (?:\d+)
44
- (?:\,\d{2})+
45
- (?:\,\d{3})
46
- (?:\.\d{2})?
47
- )
48
- \z/ix
49
-
50
- # 1,1123,4567.89
51
- CHINESE_NUMERIC_REGEX = /\A
52
- [\+\-]?
53
- (?:
54
- (?:\d+)
55
- (?:\,\d{4})+
56
- (?:\.\d{2})?
57
- )
58
- \z/ix
59
-
60
- def self.parse(input, currency = nil, **options)
61
- new.parse(input, currency, **options)
62
- end
63
-
64
- def parse(input, currency = nil, strict: false)
65
- currency = Money::Helpers.value_to_currency(currency)
66
- amount = extract_amount_from_string(input, currency, strict)
67
- Money.new(amount, currency)
68
- end
69
-
70
- private
71
-
72
- def extract_amount_from_string(input, currency, strict)
73
- unless input.is_a?(String)
74
- return input
75
- end
76
-
77
- if input.strip.empty?
78
- return '0'
79
- end
80
-
81
- number = input.scan(NUMERIC_REGEX).flatten.first
82
- number = number.to_s.strip
83
-
84
- if number.empty?
85
- if Money.config.legacy_deprecations && !strict
86
- Money.deprecate("invalid money strings will raise in the next major release \"#{input}\"")
87
- return '0'
88
- else
89
- raise MoneyFormatError, "invalid money string: #{input}"
90
- end
91
- end
92
-
93
- marks = number.scan(/[#{ESCAPED_MARKS}]/).flatten
94
- if marks.empty?
95
- return number
96
- end
97
-
98
- if marks.size == 1
99
- return normalize_number(number, marks, currency)
100
- end
101
-
102
- # remove end of string mark
103
- number.sub!(/[#{ESCAPED_MARKS}]\z/, '')
104
-
105
- if amount = number[DOT_DECIMAL_REGEX] || number[INDIAN_NUMERIC_REGEX] || number[CHINESE_NUMERIC_REGEX]
106
- return amount.tr(ESCAPED_NON_DOT_MARKS, '')
107
- end
108
-
109
- if amount = number[COMMA_DECIMAL_REGEX]
110
- return amount.tr(ESCAPED_NON_COMMA_MARKS, '').sub(',', '.')
111
- end
112
-
113
- if Money.config.legacy_deprecations && !strict
114
- Money.deprecate("invalid money strings will raise in the next major release \"#{input}\"")
115
- else
116
- raise MoneyFormatError, "invalid money string: #{input}"
117
- end
118
-
119
- normalize_number(number, marks, currency)
120
- end
121
-
122
- def normalize_number(number, marks, currency)
123
- digits = number.rpartition(marks.last)
124
- digits.first.tr!(ESCAPED_MARKS, '')
125
-
126
- if last_digits_decimals?(digits, marks, currency)
127
- "#{digits.first}.#{digits.last}"
128
- else
129
- "#{digits.first}#{digits.last}"
130
- end
131
- end
132
-
133
- def last_digits_decimals?(digits, marks, currency)
134
- # Thousands marks are always different from decimal marks
135
- # Example: 1,234,456
136
- *other_marks, last_mark = marks
137
- other_marks.uniq!
138
- if other_marks.size == 1
139
- return other_marks.first != last_mark
140
- end
141
-
142
- # Thousands always have more than 2 digits
143
- # Example: 1,23 must be 1 dollar and 23 cents
144
- if digits.last.size < 3
145
- return !digits.last.empty?
146
- end
147
-
148
- # 0 before the final mark indicates last digits are decimals
149
- # Example: 0,23
150
- if digits.first.to_i.zero?
151
- return true
152
- end
153
-
154
- # legacy support for 1.000 USD
155
- if digits.last.size == 3 && digits.first.size <= 3 && currency.minor_units < 3
156
- return false
157
- end
158
-
159
- # The last mark matches the one used by the provided currency to delimiter decimals
160
- currency.decimal_mark == last_mark
161
- end
162
- end