diff --git a/lib/monetize.rb b/lib/monetize.rb index dfedaba7d..28dfbfa2f 100644 --- a/lib/monetize.rb +++ b/lib/monetize.rb @@ -4,7 +4,7 @@ require 'monetize/core_extensions' require 'monetize/errors' require 'monetize/version' -require 'monetize/parser' +require 'monetize/optimistic_parser' require 'monetize/collection' module Monetize @@ -26,6 +26,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 @@ -36,7 +40,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) @@ -77,5 +81,28 @@ 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.default_parser = :optimistic diff --git a/lib/monetize/optimistic_parser.rb b/lib/monetize/optimistic_parser.rb new file mode 100644 index 000000000..dd1c41e16 --- /dev/null +++ b/lib/monetize/optimistic_parser.rb @@ -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 + /(? 3, 'M' => 6, 'B' => 9, 'T' => 12 } MULTIPLIER_SUFFIXES.default = 0 - MULTIPLIER_REGEXP = Regexp.new(format('^(.*?\d)(%s)\b([^\d]*)$', MULTIPLIER_SUFFIXES.keys.join('|')), 'i') - DEFAULT_DECIMAL_MARK = '.'.freeze - - def initialize(input, fallback_currency = Money.default_currency, options = {}) - @input = input.to_s.strip - @fallback_currency = fallback_currency - @options = options + def initialize(input, fallback_currency, options) + raise NotImplementedError, 'Monetize::Parser subclasses must implement #initialize' 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 - - def to_big_decimal(value) - BigDecimal(value) - rescue ::ArgumentError => err - fail ParseError, err.message - end - - attr_reader :input, :fallback_currency, :options - - def parse_currency - computed_currency = nil - computed_currency = input[/[A-Z]{2,3}/] - computed_currency = nil unless Monetize::Parser::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 - /(?