minting 2.1.1 → 2.3.0

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.
@@ -1,22 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # @!parse
4
- # # Top-level alias for {Money::Currency}, opt-in via +require 'minting/aliases'+.
5
- # Currency = Money::Currency
4
+ # # Top-level alias for {Mint::Currency}, opt-in via +require 'minting/aliases'+.
5
+ # Currency = Mint::Currency
6
6
 
7
- # Top-level alias for {Money::Currency}, opt-in via +require 'minting/aliases'+.
7
+ # Top-level alias for {Mint::Currency}, opt-in via +require 'minting/aliases'+.
8
8
  #
9
9
  # {::Currency} is **not** auto-bound by `require 'minting'` because
10
10
  # application domain models are commonly named +Currency+ (e.g. a Rails
11
11
  # model). Load this file explicitly to opt in.
12
12
  #
13
13
  # If {::Currency} is already defined, a warning is emitted and the existing
14
- # constant is preserved — use {Money::Currency} explicitly in that case.
14
+ # constant is preserved — use {Mint::Currency} explicitly in that case.
15
15
  #
16
- # @see Money::Currency
16
+ # @see Mint::Currency
17
17
 
18
- Currency = Money::Currency unless defined?(Currency)
18
+ Currency = Mint::Currency unless defined?(Currency)
19
19
 
20
- if Currency != Money::Currency
21
- warn "minting: top-level Currency was already defined (#{Currency}); skipping alias. Use Money::Currency"
20
+ if Currency != Mint::Currency
21
+ warn "minting: top-level Currency was already defined (#{Currency}); skipping alias. Use Mint::Currency"
22
22
  end
@@ -89,13 +89,7 @@ module Mint
89
89
  # usd.normalize_amount("5.25") #=> (21/4)
90
90
  #
91
91
  # @see Money.with_rounding Custom rounding modes via {Money.with_rounding}
92
- def normalize_amount(amount)
93
- if Currency.custom_rounding_active?
94
- amount.to_r.round(subunit, half: Thread.current[Currency::ROUNDING_THREAD_KEY])
95
- else
96
- amount.to_r.round(subunit)
97
- end
98
- end
92
+ def normalize_amount(amount) = amount.to_r.round(subunit)
99
93
 
100
94
  # Returns the cached frozen zero-Money for this currency.
101
95
  #
@@ -32,11 +32,15 @@ module Mint
32
32
  # @param subunit [Integer] the decimal subunit precision, defaults to 0
33
33
  # @param symbol [String] the display symbol
34
34
  # @param priority [Integer] parser precedence priority
35
+ # @param country [String, nil] associated country code
36
+ # @param name [String, nil] currency name
37
+ # @param disambiguate_symbol [String, nil] symbol variant for disambiguation
35
38
  # @return [Currency] the newly registered Currency instance
36
39
  # @raise [ArgumentError] if the code contains invalid characters
37
40
  # @raise [KeyError] if the currency code is already registered
38
- def Currency.register(code:, subunit: 0, symbol: '', priority: 0)
39
- Registry.register(code:, subunit:, symbol:, priority:)
41
+ def Currency.register(code:, subunit: 0, symbol: '', priority: 0, country: nil, name: nil,
42
+ disambiguate_symbol: nil)
43
+ Registry.register(code:, subunit:, symbol:, priority:, country:, name:, disambiguate_symbol:)
40
44
  end
41
45
 
42
46
  # Registers all built-in crypto currencies at once.
@@ -124,11 +128,8 @@ module Mint
124
128
  # @raise [Mint::UnknownCurrency] if the currency can't be resolved
125
129
  def Currency.zero(currency) = Registry.zero_for(Currency.resolve!(currency))
126
130
 
127
- # --- @api private ---
128
-
129
131
  # Returns the frozen hash of all built-in ISO 4217 world currencies.
130
132
  #
131
133
  # @return [Hash{String => Currency}] ISO-4217 world currencies mapped by code
132
- # @api private
133
134
  def Currency.world_currencies = Registry.world_currencies
134
135
  end
@@ -4,6 +4,14 @@
4
4
  module Mint
5
5
  # :nodoc:
6
6
  class Currency
7
+ # Replaces the default normalization method after custom rounding is first
8
+ # requested, keeping the default path free of dispatch and thread checks.
9
+ module CustomRounding
10
+ def normalize_amount(amount)
11
+ amount.to_r.round(subunit, half: Thread.current[Currency::ROUNDING_THREAD_KEY] || :up)
12
+ end
13
+ end
14
+
7
15
  # @api private
8
16
  VALID_ROUNDING_MODES = %i[up down even].freeze
9
17
 
@@ -17,7 +25,12 @@ module Mint
17
25
  # Activates the custom rounding dispatch path in {#normalize_amount}.
18
26
  # Once called, this cannot be reversed for the lifetime of the process.
19
27
  # @api private
20
- def self.activate_custom_rounding! = @custom_rounding_active = true
28
+ def self.activate_custom_rounding!
29
+ return if @custom_rounding_active
30
+
31
+ @custom_rounding_active = true
32
+ prepend(CustomRounding)
33
+ end
21
34
 
22
35
  # Returns the currently active rounding mode, falling back to +:up+.
23
36
  # @api private
@@ -11,11 +11,16 @@ class Numeric
11
11
  # @return [Money] self interpreted as EUR
12
12
  def euros = Mint::Money.from(self, 'EUR')
13
13
 
14
- # @param currency [String, Symbol, Currency] target currency
14
+ # @param currency [String, Currency] target currency
15
15
  # @return [Money] self interpreted as the given currency
16
16
  def to_money(currency) = Mint::Money.from(self, currency)
17
17
 
18
18
  alias dollar dollars
19
19
  alias euro euros
20
- alias mint to_money
20
+
21
+ # @deprecated Use +to_money+ instead.
22
+ def mint(currency)
23
+ warn 'DEPRECATION: Numeric#mint is deprecated; use #to_money instead.', uplevel: 1
24
+ to_money(currency)
25
+ end
21
26
  end
@@ -4,7 +4,7 @@
4
4
  class String
5
5
  # Parses self as a numeric string and creates a Money in the given currency.
6
6
  #
7
- # @param currency [String, Symbol, Currency] target currency
7
+ # @param currency [String, Currency] default currency when self has no currency marker
8
8
  # @return [Money]
9
- def to_money(currency = nil) = Mint::Money.parse(self, currency)
9
+ def to_money(currency = nil) = Mint::Money.parse(self, default_currency: currency)
10
10
  end
@@ -11,6 +11,8 @@ module Mint
11
11
 
12
12
  # Creates a new {Money} instance with the given amount and currency code.
13
13
  #
14
+ # @deprecated Use {Money.from} instead.
15
+ #
14
16
  # @param amount [Numeric] the financial value
15
17
  # @param currency_code [String, Currency, Money, nil] Currency code, object,
16
18
  # Money whose currency to reuse, or +nil+. Passed through
@@ -20,5 +22,8 @@ module Mint
20
22
  # @raise [ArgumentError] if the amount is not a Numeric
21
23
  # @raise [Mint::UnknownCurrency] if the currency code is not registered.
22
24
  # +Mint::UnknownCurrency+ inherits from +ArgumentError+.
23
- def self.money(amount, currency_code) = Money.from(amount, currency_code)
25
+ def self.money(amount, currency_code)
26
+ warn 'DEPRECATION: Mint.money is deprecated; use Money.from instead.', uplevel: 1
27
+ Money.from(amount, currency_code)
28
+ end
24
29
  end
@@ -18,7 +18,9 @@ module Mint
18
18
  @crypto_currencies || CRYPTO_MUTEX.synchronize do
19
19
  @crypto_currencies ||= begin
20
20
  path = File.join(File.expand_path('../../data', __dir__), 'crypto-currencies.yaml')
21
- YAML.load_file(path).map { |entry| Currency.new(**entry.transform_keys(&:to_sym)) }.freeze
21
+ YAML.safe_load_file(path, aliases: false)
22
+ .map { |entry| Currency.new(**entry.transform_keys(&:to_sym)) }
23
+ .freeze
22
24
  end
23
25
  end
24
26
  end
@@ -40,7 +42,8 @@ module Mint
40
42
 
41
43
  codes.map do |code|
42
44
  c = entries[index[code]]
43
- Currency.register(code:, subunit: c.subunit, symbol: c.symbol, priority: c.priority)
45
+ Currency.register(code:, subunit: c.subunit, symbol: c.symbol, priority: c.priority,
46
+ country: c.country, name: c.name, disambiguate_symbol: c.disambiguate_symbol)
44
47
  end
45
48
  end
46
49
 
@@ -52,7 +55,8 @@ module Mint
52
55
  # @return [Array<Currency>] the newly registered Currency objects
53
56
  def self.register_all_crypto
54
57
  crypto_currencies.map do |c|
55
- Currency.register(code: c.code, subunit: c.subunit, symbol: c.symbol, priority: c.priority)
58
+ Currency.register(code: c.code, subunit: c.subunit, symbol: c.symbol, priority: c.priority,
59
+ country: c.country, name: c.name, disambiguate_symbol: c.disambiguate_symbol)
56
60
  end
57
61
  end
58
62
  end
@@ -9,10 +9,14 @@ module Mint
9
9
  # @param subunit [Integer] the decimal subunit precision, defaults to 0
10
10
  # @param symbol [String] the display symbol
11
11
  # @param priority [Integer] parser precedence priority
12
+ # @param country [String, nil] associated country code
13
+ # @param name [String, nil] currency name
14
+ # @param disambiguate_symbol [String, nil] symbol variant for disambiguation
12
15
  # @return [Currency] the newly registered Currency instance
13
16
  # @raise [ArgumentError] if the code contains invalid characters
14
17
  # @raise [KeyError] if the currency code is already registered
15
- def self.register(code:, subunit: 0, symbol: '', priority: 0)
18
+ def self.register(code:, subunit: 0, symbol: '', priority: 0, country: nil, name: nil,
19
+ disambiguate_symbol: nil)
16
20
  raise ArgumentError, 'Currency code must be String' unless code.is_a? String
17
21
  unless code.match?(/^[A-Z_]+$/)
18
22
  raise ArgumentError,
@@ -22,7 +26,7 @@ module Mint
22
26
  MUTEX.synchronize do
23
27
  raise KeyError, "Currency: #{code} already registered" if currencies[code]
24
28
 
25
- currency = Currency.new(code:, subunit:, symbol:, priority:)
29
+ currency = Currency.new(code:, subunit:, symbol:, priority:, country:, name:, disambiguate_symbol:)
26
30
  @currencies = @currencies.merge(code => currency).freeze
27
31
  @symbols_list = nil
28
32
  currency
@@ -16,7 +16,7 @@ module Mint
16
16
 
17
17
  # Preload world currencies from YAML file during module load.
18
18
  path = File.join(File.expand_path('../../data', __dir__), 'world-currencies.yaml')
19
- @world_currencies = YAML.load_file(path).to_h do |entry|
19
+ @world_currencies = YAML.safe_load_file(path, aliases: false).to_h do |entry|
20
20
  [entry['code'], Currency.new(**entry.transform_keys(&:to_sym))]
21
21
  end
22
22
  @currencies = @world_currencies.freeze.dup.freeze
@@ -5,13 +5,18 @@ module Mint
5
5
  class Money
6
6
  # Proportionally allocates the monetary amount among a list of ratios.
7
7
  # Disperses any subunit rounding amounts across the initial slots
8
- # @param proportions [Array<Numeric>] a list of numeric proportions/ratios to allocate by
8
+ # @param proportions [Array<Numeric>] a list of numeric proportions/ratios to allocate by.
9
+ # Negative ratios produce signed allocations and should be used only when
10
+ # that distribution is intentional.
9
11
  # @return [Array<Money>] the list of newly allocated Money objects
10
12
  # @raise [ArgumentError] if the proportions list is empty or sums to zero
11
13
  #
12
14
  # @example Proportional allocation
13
15
  # money = Money.from(10.00, 'USD')
14
16
  # money.allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
17
+ #
18
+ # @example Signed allocation
19
+ # money.allocate([-1, 2]) #=> [[USD -10.00], [USD 20.00]]
15
20
  def allocate(proportions)
16
21
  whole = proportions.sum.to_r
17
22
  raise ArgumentError, 'Need at least 1 proportion element' if proportions.empty?
@@ -8,28 +8,20 @@ module Mint
8
8
  # Bounds may be:
9
9
  # - nil meaning no boundary
10
10
  # - same-currency {Money} or Range
11
- # - Numeric amount, or Range
12
- #
13
- # Numeric is interpreted as an amount in +self+'s currency, so the common
14
- # pricing idiom +price.clamp(0, 100)+ reads as "0 to 100 in the same
15
- # currency as +price+".
16
11
  #
17
12
  # When +self+ is already in range the receiver is returned (no new object
18
13
  # allocated). When out of range, the nearest bound is returned as a new
19
14
  # frozen {Money} in +self+'s currency.
20
15
  #
21
- # @param min_or_range [Money, Numeric, Range, nil] lower bound (inclusive), or range
22
- # @param max [Money, Numeric, nil] upper bound (inclusive)
16
+ # @param min_or_range [Money, Range, nil] lower bound (inclusive), or range
17
+ # @param max [Money, nil] upper bound (inclusive)
23
18
  # @return [Money] +self+ if in range, otherwise the nearer bound
24
- # @raise [ArgumentError] if +min+ or +max+ is not a Money, Numeric or nil; if
19
+ # @raise [ArgumentError] if +min+ or +max+ is not a Money or nil; if
25
20
  # a Money operand has a different currency; if +min+ > +max+;
26
21
  # if min is a Range, and max is not nil
27
22
  #
28
23
  # @example In range
29
- # Money.from(5, 'USD').clamp(0, 10) #=> [USD 5.00] (returns self)
30
- #
31
- # @example Out of range, with Numeric bounds
32
- # Money.from(50, 'USD').clamp(0, 10) #=> [USD 10.00]
24
+ # Money.from(5, 'USD').clamp(Money.from(0, 'USD'), Money.from(10, 'USD')) #=> [USD 5.00] (returns self)
33
25
  #
34
26
  # @example Out of range, with Money bounds
35
27
  # loss = Money.from(-5, 'USD')
@@ -37,8 +29,6 @@ module Mint
37
29
  # ceil = Money.from(10, 'USD')
38
30
  # loss.clamp(floor, ceil) #=> [USD 0.00]
39
31
  #
40
- # @example Subunit-0 currency (JPY)
41
- # Money.from(500, 'JPY').clamp(0, 100) #=> [JPY 100]
42
32
  def clamp(min_or_range, max = nil)
43
33
  if min_or_range.is_a?(Range)
44
34
  raise(ArgumentError, "Either amount range alone or two amounts accepted: #{max}") if max
@@ -56,10 +46,10 @@ module Mint
56
46
  # @private
57
47
  def normalize_boundary(boundary)
58
48
  case boundary
59
- in NilClass | Numeric then boundary
49
+ in NilClass then boundary
60
50
  in Money if same_currency?(boundary) then boundary.amount
61
51
  in Money then raise ArgumentError, "Boundary currency must be: #{currency_code}"
62
- else raise ArgumentError, "Boundary must be Numeric or Money #{boundary}"
52
+ else raise ArgumentError, "Boundary must be Money or nil: #{boundary}"
63
53
  end
64
54
  end
65
55
  end
@@ -44,7 +44,7 @@ module Mint
44
44
  #
45
45
  # @param subunits [Integer] the amount expressed in the currency's
46
46
  # smallest unit (e.g. cents). Must be an Integer to preserve exactness.
47
- # @param currency [String, Symbol, Currency] the currency identifier
47
+ # @param currency [String, Currency] the currency identifier
48
48
  # @return [Money] the resulting Money instance
49
49
  # @raise [ArgumentError] if +subunits+ is not an Integer
50
50
  # @raise [Mint::UnknownCurrency] if +currency+ is not registered
@@ -57,6 +57,9 @@ module Mint
57
57
  # @example Round-trip
58
58
  # m = Money.from(134120, "BRL")
59
59
  # Money.from_hash(m.to_hash) == m #=> true
60
+ #
61
+ # JSON and Rails `as_json` integration are provided by the
62
+ # +money_attribute+ companion gem rather than the core Minting gem.
60
63
  def self.from_hash(hash)
61
64
  currency = Currency.resolve!(hash[:currency] || hash['currency'])
62
65
  amount = currency.normalize_amount(Rational(hash[:amount] || hash['amount']))
@@ -7,7 +7,12 @@ module Mint
7
7
  # and decimal separator.
8
8
  #
9
9
  # @param template [String, Hash, nil] Either a format string with placeholders
10
- # (%<symbol>s, %<amount>f, %<currency>s, %<integral>d, %<fractional>d, %<dsymbol>s),
10
+ # (%<symbol>s, %<amount>f, %<magnitude>f, %<sign>s, %<currency>s,
11
+ # %<integral>d, %<fractional>d, %<dsymbol>s),
12
+ # `%<amount>` is signed; `%<magnitude>` is always non-negative; `%<fractional>`
13
+ # is the non-negative fractional component; and `%<sign>` is `+`, `-`, or
14
+ # an empty string for zero. Use `%<sign>` when the sign needs explicit
15
+ # placement, especially for amounts between -1 and 1.
11
16
  # or a Hash with per-sign keys (:positive, :negative, :zero) each
12
17
  # holding a format string. A Hash is convenient for sign-aware formats
13
18
  # such as accounting parentheses:
@@ -44,12 +49,16 @@ module Mint
44
49
  # money.format('%<currency>s %<amount>f') #=> "USD 1234.56"
45
50
  # money.format('%<amount>f %<symbol>s') #=> "1234.56 $"
46
51
  # money.format('%<symbol>s%<amount>+f') #=> "$+1234.56"
52
+ # money.format('%<sign>s%<symbol>s%<magnitude>f') #=> "+$1234.56"
47
53
  #
48
54
  # @example Integral & fractional parts
49
55
  # money.format('%<integral>d.%<fractional>02d') #=> "1234.56"
50
56
  # price = Money.from(0.99, 'USD')
51
57
  # price.format('%<integral>d dollars and %<fractional>02d cents')
52
58
  # #=> "0 dollars and 99 cents"
59
+ # loss = Money.from(-0.99, 'USD')
60
+ # loss.format('%<sign>s%<integral>d.%<fractional>02d')
61
+ # #=> "-0.99"
53
62
  #
54
63
  # @example Per-sign Hash format (accounting parentheses)
55
64
  # loss = Money.from(-1234.56, 'USD')
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'monitor'
4
+
3
5
  module Mint
4
6
  class Money
5
7
  # Compiles and caches formatter lambdas for a fixed combination of format
@@ -12,9 +14,22 @@ module Mint
12
14
  class Formatter
13
15
  extend FormatterValidator
14
16
 
15
- def self.cache = @cache ||= {}
17
+ # Keep enough compiled configurations for typical application presets and
18
+ # locales without retaining every caller-provided template indefinitely.
19
+ CACHE_LIMIT = 256
20
+ CACHE_MUTEX = Monitor.new
21
+
22
+ private_constant :CACHE_MUTEX
23
+
24
+ @cache = {}.freeze
16
25
 
17
- # Returns a cached {Formatter} for the given configuration.
26
+ class << self
27
+ attr_reader :cache
28
+ end
29
+
30
+ # Returns a cached {Formatter} for the given configuration. The cache is
31
+ # thread-safe and bounded by {CACHE_LIMIT}; once full, new configurations
32
+ # are compiled without being retained.
18
33
  # @param format [Hash{Symbol => String}] per-sign templates
19
34
  # @param decimal [String] decimal separator
20
35
  # @param thousand [String, false] thousands delimiter (+false+ disables)
@@ -23,19 +38,25 @@ module Mint
23
38
  formatter = cache[key]
24
39
  return formatter if formatter
25
40
 
26
- validate_format!(format)
27
- validate_separators!(decimal:, thousand:)
41
+ CACHE_MUTEX.synchronize do
42
+ formatter = cache[key]
43
+ return formatter if formatter
44
+
45
+ validate_format!(format)
46
+ validate_separators!(decimal:, thousand:)
28
47
 
29
- cache[key] = new(format, decimal, thousand)
48
+ formatter = new(format, decimal, thousand)
49
+ @cache = cache.merge(key => formatter).freeze unless cache.size >= CACHE_LIMIT
50
+ formatter
51
+ end
30
52
  end
31
53
 
32
54
  def initialize(format, decimal, thousand)
33
- @format = format
34
55
  @decimal = decimal
35
- @thousand = thousand
36
- compile
56
+ compile(format:, thousand:)
37
57
  end
38
58
 
59
+ SIGNS = { -1 => '-', 0 => '', 1 => '+' }.freeze
39
60
  SUBUNIT_PLACEHOLDER = "\uE000"
40
61
  # Matches a digit followed by groups of exactly 3 digits that terminate
41
62
  # at a non-digit or end-of-string. Used to insert thousand separators.
@@ -48,9 +69,12 @@ module Mint
48
69
 
49
70
  templates = @has_placeholder ? @templates_by_subunit[currency.subunit] : @templates
50
71
 
51
- template = templates[amount <=> 0] || templates[1]
72
+ cmp = amount <=> 0
73
+ template = templates[cmp] || templates[1]
74
+ sign = SIGNS[cmp]
52
75
 
53
- display_amount = @has_negative_template && amount < 0 ? -amount : amount
76
+ magnitude = amount.abs
77
+ display_amount = @has_negative_template ? magnitude : amount
54
78
  integral = display_amount.to_i
55
79
 
56
80
  result = Kernel.format(template,
@@ -58,6 +82,8 @@ module Mint
58
82
  dsymbol: @needs_dsymbol && currency.dsymbol,
59
83
  symbol: currency.symbol,
60
84
  amount: display_amount,
85
+ magnitude:,
86
+ sign:,
61
87
  integral: integral,
62
88
  fractional: @needs_fractional ? money.fractional.abs : 0)
63
89
  apply_separators(result, integral)
@@ -78,25 +104,27 @@ module Mint
78
104
  result
79
105
  end
80
106
 
81
- def compile
82
- @templates = { -1 => @format[:negative], 0 => @format[:zero], 1 => @format[:positive] || Money::DEFAULT_FORMAT }
107
+ def compile(format:, thousand:)
108
+ @templates = { -1 => format[:negative], 0 => format[:zero], 1 => format[:positive] || Money::DEFAULT_FORMAT }
83
109
  @templates.compact!
84
- # Inject subunit precision into %<amount>f specs that lack an explicit
85
- # precision. Matches "%<amount>f" or "%+10<amount>f" (with optional
86
- # flags/width before the named ref) and appends a placeholder for the
87
- # currency subunit digits e.g. "%<amount>f" "%<amount>\uE000f".
110
+
111
+ # Inject subunit precision into amount and magnitude f specs that lack
112
+ # an explicit precision. Matches "%<amount>f" or "%<magnitude>f" and
113
+ # appends a placeholder for the currency subunit digits.
88
114
  # The placeholder is later replaced with the actual subunit count at
89
115
  # format time (e.g. "\uE000" → "2" for USD, "0" for JPY).
90
- @templates.transform_values! { |f| f.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{SUBUNIT_PLACEHOLDER}f") }
116
+ @templates.transform_values! do |f|
117
+ f.gsub(/%<(amount|magnitude)>(\s*\+?\d*)f/, "%<\\1>\\2.#{SUBUNIT_PLACEHOLDER}f")
118
+ end
91
119
  @has_negative_template = @templates.key?(-1)
92
-
93
120
  joined = @templates.values.join
94
121
  @needs_fractional = joined.include?('%<fractional>')
95
122
  @needs_dsymbol = joined.include?('%<dsymbol>')
96
-
97
- @needs_thousand_substitution = @thousand && !@thousand.empty? &&
98
- (joined.include?('%<amount>') || joined.include?('%<integral>'))
99
- @thousand_replacement = "\\1#{@thousand}" if @needs_thousand_substitution
123
+ @needs_thousand_substitution = thousand && !thousand.empty? &&
124
+ (joined.include?('%<amount>') ||
125
+ joined.include?('%<magnitude>') ||
126
+ joined.include?('%<integral>'))
127
+ @thousand_replacement = "\\1#{thousand}" if @needs_thousand_substitution
100
128
 
101
129
  @has_placeholder = joined.include?(SUBUNIT_PLACEHOLDER)
102
130
  return unless @has_placeholder
@@ -30,13 +30,14 @@ module Mint
30
30
  return format unless Mint.locale_backend.nil?
31
31
 
32
32
  subunit = currency.subunit
33
- major = integral.to_s
33
+ sign = amount.negative? ? '-' : ''
34
+ major = integral.abs.to_s
34
35
  major.gsub!(THOUSAND_RE, '\1,') if amount.abs >= 1000
35
36
  if subunit > 0
36
37
  minor = fractional.abs.to_s.rjust(subunit, '0')
37
- "#{currency.symbol}#{major}.#{minor}"
38
+ "#{currency.symbol}#{sign}#{major}.#{minor}"
38
39
  else
39
- "#{currency.symbol}#{major}"
40
+ "#{currency.symbol}#{sign}#{major}"
40
41
  end
41
42
  end
42
43
  end
@@ -52,7 +52,8 @@ module Mint
52
52
 
53
53
  alias to_i integral
54
54
 
55
- # Returns the fractional part of the amount.
55
+ # Returns the signed fractional part of the amount. Formatting templates
56
+ # expose this value as a magnitude through `%<fractional>d`.
56
57
  # @example
57
58
  # Money.from(1234.56, 'USD').fractional #=> 56
58
59
  # Money.from(1000, 'JPY').fractional #=> 0
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mint
4
+ class Money
5
+ # Parses numeric input when the decimal separator is explicitly known.
6
+ # @api private
7
+ module SeparatorParser
8
+ module_function
9
+
10
+ def parse(numeric, decimal, thousand)
11
+ decimal, thousand = separators(decimal, thousand)
12
+ return nil unless decimal && thousand && decimal != thousand
13
+
14
+ sign = numeric.start_with?('-', '+') ? numeric[0] : ''
15
+ unsigned = numeric.delete_prefix(sign)
16
+ return nil if unsigned.count(decimal) > 1
17
+
18
+ return parse_decimal(sign, unsigned, decimal, thousand) if unsigned.include?(decimal)
19
+ return parse_thousands(sign, unsigned, thousand) if unsigned.include?(thousand)
20
+ return "#{sign}#{unsigned}" if unsigned.match?(/\A\d+\z/)
21
+
22
+ nil
23
+ end
24
+
25
+ def separators(decimal, thousand)
26
+ return [decimal, thousand] if decimal && thousand
27
+ return [decimal, decimal == ',' ? '.' : ','] if %w[. ,].include?(decimal)
28
+ return [thousand == ',' ? '.' : ',', thousand] if %w[. ,].include?(thousand)
29
+
30
+ nil
31
+ end
32
+ private_class_method :separators
33
+
34
+ def parse_decimal(sign, unsigned, decimal, thousand)
35
+ integral, fractional = unsigned.split(decimal, 2)
36
+ return nil unless valid_integer_part?(integral, thousand) && fractional.match?(/\A\d+\z/)
37
+
38
+ "#{sign}#{integral.delete(thousand)}.#{fractional}"
39
+ end
40
+ private_class_method :parse_decimal
41
+
42
+ def parse_thousands(sign, unsigned, thousand)
43
+ return nil unless valid_integer_part?(unsigned, thousand)
44
+
45
+ "#{sign}#{unsigned.delete(thousand)}"
46
+ end
47
+ private_class_method :parse_thousands
48
+
49
+ def valid_integer_part?(integer, thousand)
50
+ return integer.match?(/\A\d+\z/) unless integer.include?(thousand)
51
+
52
+ groups = integer.split(thousand)
53
+ groups.first.match?(/\A\d{1,3}\z/) && groups.drop(1).all? { |group| group.match?(/\A\d{3}\z/) }
54
+ end
55
+ private_class_method :valid_integer_part?
56
+ end
57
+
58
+ private_constant :SeparatorParser
59
+ end
60
+ end