shopify-money 1.0.1.pre → 1.0.2.pre

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 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