Skip to content

Commit 014c5c9

Browse files
committed
Improve currency symbol detection
1 parent c8430d6 commit 014c5c9

File tree

3 files changed

+70
-25
lines changed

3 files changed

+70
-25
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ test/tmp
1616
test/version_tmp
1717
tmp
1818
.ruby-version
19+
.ruby-gemset

lib/monetize/parser.rb

Lines changed: 54 additions & 21 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,15 +28,30 @@ class Parser
2828
'S$' => 'SGD',
2929
'HK$'=> 'HKD',
3030
'NT$'=> 'TWD',
31-
'₱' => 'PHP',
32-
}
31+
'₱' => 'PHP'
32+
}.freeze
33+
# FIXME: This ignored symbols could be ambiguous or conflict with other symbols
34+
IGNORED_SYMBOLS = ['kr', 'NIO$', 'UM', 'L', 'oz t', "so'm", 'CUC$'].freeze
3335

3436
MULTIPLIER_SUFFIXES = { 'K' => 3, 'M' => 6, 'B' => 9, 'T' => 12 }
3537
MULTIPLIER_SUFFIXES.default = 0
3638
MULTIPLIER_REGEXP = Regexp.new(format('^(.*?\d)(%s)\b([^\d]*)$', MULTIPLIER_SUFFIXES.keys.join('|')), 'i')
3739

3840
DEFAULT_DECIMAL_MARK = '.'.freeze
3941

42+
def self.currency_symbols
43+
@@currency_symbols ||= Money::Currency.table.reduce(INITIAL_CURRENCY_SYMBOLS.dup) do |memo, (_, currency)|
44+
symbol = currency[:symbol]
45+
symbol = currency[:disambiguate_symbol] if symbol && memo.key?(symbol)
46+
47+
next memo if is_invalid_currency_symbol?(symbol)
48+
49+
memo[symbol] = currency[:iso_code] unless memo.value?(currency[:iso_code])
50+
51+
memo
52+
end.freeze
53+
end
54+
4055
def initialize(input, fallback_currency = Money.default_currency, options = {})
4156
@input = input.to_s.strip
4257
@fallback_currency = fallback_currency
@@ -65,6 +80,17 @@ def parse
6580

6681
private
6782

83+
def self.is_invalid_currency_symbol?(symbol)
84+
currency_symbol_blank?(symbol) ||
85+
symbol.include?('.') || # Ignore symbols with dots because they can be confused with decimal marks
86+
IGNORED_SYMBOLS.include?(symbol) ||
87+
MULTIPLIER_REGEXP.match?("1#{symbol}") # Ignore symbols that can be confused with multipliers
88+
end
89+
90+
def self.currency_symbol_blank?(symbol)
91+
symbol.nil? || symbol.empty?
92+
end
93+
6894
def to_big_decimal(value)
6995
BigDecimal(value)
7096
rescue ::ArgumentError => err
@@ -74,11 +100,8 @@ def to_big_decimal(value)
74100
attr_reader :input, :fallback_currency, :options
75101

76102
def parse_currency
77-
computed_currency = nil
78-
computed_currency = input[/[A-Z]{2,3}/]
79-
computed_currency = nil unless Monetize::Parser::CURRENCY_SYMBOLS.value?(computed_currency)
80-
computed_currency ||= compute_currency if assume_from_symbol?
81-
103+
computed_currency = compute_currency_from_iso_code
104+
computed_currency ||= compute_currency_from_symbol if assume_from_symbol?
82105

83106
computed_currency || fallback_currency || Money.default_currency
84107
end
@@ -99,9 +122,18 @@ def apply_sign(negative, amount)
99122
negative ? amount * -1 : amount
100123
end
101124

102-
def compute_currency
125+
def compute_currency_from_iso_code
126+
computed_currency = input[/[A-Z]{2,4}/]
127+
128+
return unless computed_currency
129+
130+
computed_currency if self.class.currency_symbols.value?(computed_currency)
131+
end
132+
133+
def compute_currency_from_symbol
103134
match = input.match(currency_symbol_regex)
104-
CURRENCY_SYMBOLS[match.to_s] if match
135+
136+
self.class.currency_symbols[match.to_s] if match
105137
end
106138

107139
def extract_major_minor(num, currency)
@@ -127,20 +159,19 @@ def minor_has_correct_dp_for_currency_subunit?(minor, currency)
127159
def extract_major_minor_with_single_delimiter(num, currency, delimiter)
128160
if expect_whole_subunits?
129161
possible_major, possible_minor = split_major_minor(num, delimiter)
162+
130163
if minor_has_correct_dp_for_currency_subunit?(possible_minor, currency)
131-
split_major_minor(num, delimiter)
132-
else
133-
extract_major_minor_with_tentative_delimiter(num, delimiter)
164+
return [possible_major, possible_minor]
134165
end
135166
else
136-
if delimiter == currency.decimal_mark
137-
split_major_minor(num, delimiter)
138-
elsif Monetize.enforce_currency_delimiters && delimiter == currency.thousands_separator
139-
[num.gsub(delimiter, ''), 0]
140-
else
141-
extract_major_minor_with_tentative_delimiter(num, delimiter)
167+
return split_major_minor(num, delimiter) if delimiter == currency.decimal_mark
168+
169+
if Monetize.enforce_currency_delimiters && delimiter == currency.thousands_separator
170+
return [num.gsub(delimiter, ''), 0]
142171
end
143172
end
173+
174+
extract_major_minor_with_tentative_delimiter(num, delimiter)
144175
end
145176

146177
def extract_major_minor_with_tentative_delimiter(num, delimiter)
@@ -165,7 +196,9 @@ def extract_major_minor_with_tentative_delimiter(num, delimiter)
165196
end
166197

167198
def extract_multiplier
168-
if (matches = MULTIPLIER_REGEXP.match(input))
199+
matches = MULTIPLIER_REGEXP.match(input)
200+
201+
if matches
169202
multiplier_suffix = matches[2].upcase
170203
[MULTIPLIER_SUFFIXES[multiplier_suffix], "#{$1}#{$3}"]
171204
else
@@ -180,7 +213,7 @@ def extract_sign(input)
180213
end
181214

182215
def regex_safe_symbols
183-
CURRENCY_SYMBOLS.keys.map { |key| Regexp.escape(key) }.join('|')
216+
self.class.currency_symbols.keys.map { |key| Regexp.escape(key) }.join('|')
184217
end
185218

186219
def split_major_minor(num, delimiter)

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)