Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Strict parser #184

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ jobs:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Run tests
run: bundle exec rspec spec
- name: Run strict tests
run: MODE=strict bundle exec rspec spec
33 changes: 31 additions & 2 deletions lib/monetize.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
require 'monetize/core_extensions'
require 'monetize/errors'
require 'monetize/version'
require 'monetize/parser'
require 'monetize/optimistic_parser'
require 'monetize/strict_parser'
require 'monetize/collection'

module Monetize
Expand All @@ -26,6 +27,10 @@ class << self
# human text that we're dealing with fractions of cents.
attr_accessor :expect_whole_subunits

# Specify which of the previously registered parsers should be used when parsing an input
# unless overriden using the :parser keyword option for the .parse and parse! methods.
attr_accessor :default_parser

def parse(input, currency = Money.default_currency, options = {})
parse! input, currency, options
rescue Error
Expand All @@ -36,7 +41,7 @@ def parse!(input, currency = Money.default_currency, options = {})
return input if input.is_a?(Money)
return from_numeric(input, currency) if input.is_a?(Numeric)

parser = Monetize::Parser.new(input, currency, options)
parser = fetch_parser(input, currency, options)
amount, currency = parser.parse

Money.from_amount(amount, currency)
Expand Down Expand Up @@ -77,5 +82,29 @@ def extract_cents(input, currency = Money.default_currency)
money = parse(input, currency)
money.cents if money
end

# Registers a new parser class along with the default options. It can then be used by
# providing a :parser option when parsing an input or by specifying a default parser
# using Monetize.default_parser=.
def register_parser(name, klass, options = {})
@parsers ||= {}
@parsers[name] = [klass, options]
end

private

attr_reader :parsers

def fetch_parser(input, currency, options)
parser_name = options[:parser] || default_parser
parser_klass, parser_options = parsers.fetch(parser_name) do
raise ArgumentError, "Parser not registered: #{parser_name}"
end
parser_klass.new(input, currency, parser_options.merge(options))
end
end
end

Monetize.register_parser(:optimistic, Monetize::OptimisticParser)
Monetize.register_parser(:strict, Monetize::StrictParser)
Monetize.default_parser = :optimistic
168 changes: 168 additions & 0 deletions lib/monetize/optimistic_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# encoding: utf-8

require 'monetize/parser'

module Monetize
class OptimisticParser < Parser
MULTIPLIER_REGEXP = Regexp.new(format('^(.*?\d)(%s)\b([^\d]*)$', MULTIPLIER_SUFFIXES.keys.join('|')), 'i')

DEFAULT_DECIMAL_MARK = '.'.freeze

def initialize(input, fallback_currency, options)
@input = input.to_s.strip
@fallback_currency = fallback_currency
@options = options
end

def parse
currency = Money::Currency.wrap(parse_currency)

multiplier_exp, input = extract_multiplier

num = input.gsub(/(?:^#{currency.symbol}|[^\d.,'-]+)/, '')

negative, num = extract_sign(num)

num.chop! if num =~ /[\.|,]$/

major, minor = extract_major_minor(num, currency)

amount = to_big_decimal([major, minor].join(DEFAULT_DECIMAL_MARK))
amount = apply_multiplier(multiplier_exp, amount)
amount = apply_sign(negative, amount)

[amount, currency]
end

private

private

attr_reader :input, :fallback_currency, :options

def to_big_decimal(value)
BigDecimal(value)
rescue ::ArgumentError => err
fail ParseError, err.message
end

def parse_currency
computed_currency = nil
computed_currency = input[/[A-Z]{2,3}/]
computed_currency = nil unless CURRENCY_SYMBOLS.value?(computed_currency)
computed_currency ||= compute_currency if assume_from_symbol?


computed_currency || fallback_currency || Money.default_currency
end

def assume_from_symbol?
options.fetch(:assume_from_symbol) { Monetize.assume_from_symbol }
end

def expect_whole_subunits?
options.fetch(:expect_whole_subunits) { Monetize.expect_whole_subunits }
end

def apply_multiplier(multiplier_exp, amount)
amount * 10**multiplier_exp
end

def apply_sign(negative, amount)
negative ? amount * -1 : amount
end

def compute_currency
match = input.match(currency_symbol_regex)
CURRENCY_SYMBOLS[match.to_s] if match
end

def extract_major_minor(num, currency)
used_delimiters = num.scan(/[^\d]/).uniq

case used_delimiters.length
when 0
[num, 0]
when 2
thousands_separator, decimal_mark = used_delimiters
split_major_minor(num.gsub(thousands_separator, ''), decimal_mark)
when 1
extract_major_minor_with_single_delimiter(num, currency, used_delimiters.first)
else
fail ParseError, 'Invalid amount'
end
end

def minor_has_correct_dp_for_currency_subunit?(minor, currency)
minor.length == currency.subunit_to_unit.to_s.length - 1
end

def extract_major_minor_with_single_delimiter(num, currency, delimiter)
if expect_whole_subunits?
possible_major, possible_minor = split_major_minor(num, delimiter)
if minor_has_correct_dp_for_currency_subunit?(possible_minor, currency)
split_major_minor(num, delimiter)
else
extract_major_minor_with_tentative_delimiter(num, delimiter)
end
else
if delimiter == currency.decimal_mark
split_major_minor(num, delimiter)
elsif Monetize.enforce_currency_delimiters && delimiter == currency.thousands_separator
[num.gsub(delimiter, ''), 0]
else
extract_major_minor_with_tentative_delimiter(num, delimiter)
end
end
end

def extract_major_minor_with_tentative_delimiter(num, delimiter)
if num.scan(delimiter).length > 1
# Multiple matches; treat as thousands separator
[num.gsub(delimiter, ''), '00']
else
possible_major, possible_minor = split_major_minor(num, delimiter)

# Doesn't look like thousands separator
is_decimal_mark = possible_minor.length != 3 ||
possible_major.length > 3 ||
possible_major.to_i == 0 ||
(!expect_whole_subunits? && delimiter == '.')

if is_decimal_mark
[possible_major, possible_minor]
else
["#{possible_major}#{possible_minor}", '00']
end
end
end

def extract_multiplier
if (matches = MULTIPLIER_REGEXP.match(input))
multiplier_suffix = matches[2].upcase
[MULTIPLIER_SUFFIXES[multiplier_suffix], "#{$1}#{$3}"]
else
[0, input]
end
end

def extract_sign(input)
result = (input =~ /^-+(.*)$/ || input =~ /^(.*)-+$/) ? [true, $1] : [false, input]
fail ParseError, 'Invalid amount (hyphen)' if result[1].include?('-')
result
end

def regex_safe_symbols
CURRENCY_SYMBOLS.keys.map { |key| Regexp.escape(key) }.join('|')
end

def split_major_minor(num, delimiter)
major, minor = num.split(delimiter)
[major, minor || '00']
end

def currency_symbol_regex
/(?<![A-Z])(#{regex_safe_symbols})(?![A-Z])/i
end
end
end
Loading
Loading