Skip to content

Commit 9d48c33

Browse files
committed
Improve currency symbol detection
1 parent 6288094 commit 9d48c33

File tree

3 files changed

+78
-29
lines changed

3 files changed

+78
-29
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ test/version_tmp
1717
tmp
1818
.ruby-version
1919
.tool-versions
20+
.ruby-gemset

lib/monetize/parser.rb

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
module Monetize
44
class Parser
5-
CURRENCY_SYMBOLS = {
5+
INITIAL_CURRENCY_SYMBOLS = {
66
'$' => 'USD',
77
'€' => 'EUR',
88
'£' => 'GBP',
@@ -28,16 +28,35 @@ class Parser
2828
'S$' => 'SGD',
2929
'HK$'=> 'HKD',
3030
'NT$'=> 'TWD',
31-
'₱' => 'PHP',
32-
}
33-
34-
CURRENCY_SYMBOL_REGEX = /(?<![A-Z])(#{CURRENCY_SYMBOLS.keys.map { |key| Regexp.escape(key) }.join('|')})(?![A-Z])/i
31+
'₱' => 'PHP'
32+
}.freeze
33+
34+
# FIXME: This ignored symbols could be ambiguous or conflict with other symbols
35+
IGNORED_SYMBOLS = ['kr', 'NIO$', 'UM', 'L', 'oz t', "so'm", 'CUC$'].freeze
36+
3537
MULTIPLIER_SUFFIXES = { 'K' => 3, 'M' => 6, 'B' => 9, 'T' => 12 }
3638
MULTIPLIER_SUFFIXES.default = 0
37-
MULTIPLIER_REGEXP = Regexp.new(format('^(.*?\d)(%s)\b([^\d]*)$', MULTIPLIER_SUFFIXES.keys.join('|')), 'i')
39+
MULTIPLIER_REGEXP = /^(.*?\d)(#{MULTIPLIER_SUFFIXES.keys.join('|')})\b([^\d]*)$/i
3840

3941
DEFAULT_DECIMAL_MARK = '.'.freeze
4042

43+
def self.currency_symbols
44+
@@currency_symbols ||= Money::Currency.table.reduce(INITIAL_CURRENCY_SYMBOLS.dup) do |memo, (_, currency)|
45+
symbol = currency[:symbol]
46+
symbol = currency[:disambiguate_symbol] if memo.key?(symbol)
47+
48+
next memo if is_invalid_currency_symbol?(symbol)
49+
50+
memo[symbol] = currency[:iso_code] unless memo.value?(currency[:iso_code])
51+
52+
memo
53+
end.freeze
54+
end
55+
56+
def self.currency_symbol_regex
57+
@@currency_symbol_regex ||= /(?<![A-Z])(#{currency_symbols.keys.map { |key| Regexp.escape(key) }.join('|')})(?![A-Z])/i
58+
end
59+
4160
def initialize(input, fallback_currency = Money.default_currency, options = {})
4261
@input = input.to_s.strip
4362
@fallback_currency = fallback_currency
@@ -66,6 +85,17 @@ def parse
6685

6786
private
6887

88+
def self.is_invalid_currency_symbol?(symbol)
89+
currency_symbol_blank?(symbol) ||
90+
symbol.include?('.') || # Ignore symbols with dots because they can be confused with decimal marks
91+
IGNORED_SYMBOLS.include?(symbol) ||
92+
MULTIPLIER_REGEXP.match?("1#{symbol}") # Ignore symbols that can be confused with multipliers
93+
end
94+
95+
def self.currency_symbol_blank?(symbol)
96+
symbol.nil? || symbol.empty?
97+
end
98+
6999
def to_big_decimal(value)
70100
BigDecimal(value)
71101
rescue ::ArgumentError => err
@@ -75,11 +105,8 @@ def to_big_decimal(value)
75105
attr_reader :input, :fallback_currency, :options
76106

77107
def parse_currency
78-
computed_currency = nil
79-
computed_currency = input[/[A-Z]{2,3}/]
80-
computed_currency = nil unless Monetize::Parser::CURRENCY_SYMBOLS.value?(computed_currency)
81-
computed_currency ||= compute_currency if assume_from_symbol?
82-
108+
computed_currency = compute_currency_from_iso_code
109+
computed_currency ||= compute_currency_from_symbol if assume_from_symbol?
83110

84111
computed_currency || fallback_currency || Money.default_currency
85112
end
@@ -100,9 +127,18 @@ def apply_sign(negative, amount)
100127
negative ? amount * -1 : amount
101128
end
102129

103-
def compute_currency
104-
match = input.match(CURRENCY_SYMBOL_REGEX)
105-
CURRENCY_SYMBOLS[match.to_s] if match
130+
def compute_currency_from_iso_code
131+
computed_currency = input[/[A-Z]{2,4}/]
132+
133+
return unless computed_currency
134+
135+
computed_currency if self.class.currency_symbols.value?(computed_currency)
136+
end
137+
138+
def compute_currency_from_symbol
139+
match = input.match(self.class.currency_symbol_regex)
140+
141+
self.class.currency_symbols[match.to_s] if match
106142
end
107143

108144
def extract_major_minor(num, currency)
@@ -127,21 +163,20 @@ def minor_has_correct_dp_for_currency_subunit?(minor, currency)
127163

128164
def extract_major_minor_with_single_delimiter(num, currency, delimiter)
129165
if expect_whole_subunits?
130-
_possible_major, possible_minor = split_major_minor(num, delimiter)
166+
possible_major, possible_minor = split_major_minor(num, delimiter)
167+
131168
if minor_has_correct_dp_for_currency_subunit?(possible_minor, currency)
132-
split_major_minor(num, delimiter)
133-
else
134-
extract_major_minor_with_tentative_delimiter(num, delimiter)
169+
return [possible_major, possible_minor]
135170
end
136171
else
137-
if delimiter == currency.decimal_mark
138-
split_major_minor(num, delimiter)
139-
elsif Monetize.enforce_currency_delimiters && delimiter == currency.thousands_separator
140-
[num.gsub(delimiter, ''), 0]
141-
else
142-
extract_major_minor_with_tentative_delimiter(num, delimiter)
172+
return split_major_minor(num, delimiter) if delimiter == currency.decimal_mark
173+
174+
if Monetize.enforce_currency_delimiters && delimiter == currency.thousands_separator
175+
return [num.gsub(delimiter, ''), 0]
143176
end
144177
end
178+
179+
extract_major_minor_with_tentative_delimiter(num, delimiter)
145180
end
146181

147182
def extract_major_minor_with_tentative_delimiter(num, delimiter)
@@ -166,7 +201,9 @@ def extract_major_minor_with_tentative_delimiter(num, delimiter)
166201
end
167202

168203
def extract_multiplier
169-
if (matches = MULTIPLIER_REGEXP.match(input))
204+
matches = MULTIPLIER_REGEXP.match(input)
205+
206+
if matches
170207
multiplier_suffix = matches[2].upcase
171208
[MULTIPLIER_SUFFIXES[multiplier_suffix], "#{$1}#{$3}"]
172209
else

spec/monetize_spec.rb

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,13 @@
5656
Monetize.assume_from_symbol = false
5757
end
5858

59-
Monetize::Parser::CURRENCY_SYMBOLS.each_pair do |symbol, iso_code|
59+
Monetize::Parser.currency_symbols.each_pair do |symbol, iso_code|
6060
context iso_code do
6161
let(:currency) { Money::Currency.find(iso_code) }
62-
let(:amount) { 5_95 }
62+
let(:amount) do
63+
# FIXME: The exponent > 3 (e.g. BTC) causes problems when converting to string from float
64+
(currency.exponent > 3)? (595 * currency.subunit_to_unit) : 595
65+
end
6366
let(:amount_in_units) { amount.to_f / currency.subunit_to_unit }
6467

6568
it 'ensures correct amount calculations for test' do
@@ -109,13 +112,21 @@
109112
end
110113

111114
it 'parses formatted inputs without currency detection when overridden' do
112-
expect(Monetize.parse("#{symbol}5.95", nil, assume_from_symbol: false)).to eq Money.new(amount, 'USD')
115+
if Monetize::Parser.currency_symbols.value?(symbol)
116+
currency_iso_code = symbol
117+
amount_str = currency.exponent == 0 ? '595' : '5.95'
118+
else
119+
currency_iso_code = 'USD'
120+
amount_str = '5.95'
121+
end
122+
123+
expect(Monetize.parse("#{symbol}#{amount_str}", nil, assume_from_symbol: false)).to eq Money.new(595, currency_iso_code)
113124
end
114125
end
115126
end
116127

117128
it 'should assume default currency if not a recognised symbol' do
118-
expect(Monetize.parse('L9.99')).to eq Money.new(999, 'USD')
129+
expect(Monetize.parse('NRS9.99')).to eq Money.new(999, 'USD')
119130
end
120131

121132
it 'should use provided currency over symbol' do

0 commit comments

Comments
 (0)