minting 2.1.0 → 2.2.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
@@ -124,11 +124,8 @@ module Mint
124
124
  # @raise [Mint::UnknownCurrency] if the currency can't be resolved
125
125
  def Currency.zero(currency) = Registry.zero_for(Currency.resolve!(currency))
126
126
 
127
- # --- @api private ---
128
-
129
127
  # Returns the frozen hash of all built-in ISO 4217 world currencies.
130
128
  #
131
129
  # @return [Hash{String => Currency}] ISO-4217 world currencies mapped by code
132
- # @api private
133
130
  def Currency.world_currencies = Registry.world_currencies
134
131
  end
@@ -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
9
  def to_money(currency = nil) = Mint::Money.parse(self, 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
@@ -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
@@ -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
@@ -12,7 +12,7 @@ module Mint
12
12
  # @return [BigDecimal] the decimal representation of the money amount
13
13
  # @example
14
14
  # Money.from(9.99, 'USD').to_d #=> 0.999e1
15
- def to_d = amount.to_d 0
15
+ def to_d = BigDecimal(amount, 0)
16
16
 
17
17
  # Converts the monetary amount to a standard float.
18
18
  # Note: Using float conversion loses precision guarantees.
@@ -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
16
23
 
17
- # Returns a cached {Formatter} for the given configuration.
24
+ @cache = {}.freeze
25
+
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,10 +38,17 @@ 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)
@@ -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
@@ -8,7 +8,8 @@ module Mint
8
8
  # Returns +nil+ when the input is invalid or currency cannot be determined.
9
9
  #
10
10
  # @param input [String] Amount input, optionally including a currency symbol or code
11
- # @param currency [String, Symbol, Currency, nil] ISO code when not present in +input+
11
+ # @param currency [String, Currency, nil] default currency when none is present in +input+
12
+ # An embedded currency code or symbol takes precedence over this argument.
12
13
  # @return [Money, nil]
13
14
  #
14
15
  # @example With explicit currency
@@ -27,17 +28,18 @@ module Mint
27
28
  currency = parse_currency(input, currency)
28
29
  return nil unless currency
29
30
 
30
- amount = parse_amount(input)
31
+ amount = parse_amount(input, currency)
31
32
  return nil unless amount
32
33
 
33
34
  amount = currency.normalize_amount(amount)
34
- new(amount, currency)
35
+ amount.zero? ? currency.zero : new(amount, currency)
35
36
  end
36
37
 
37
38
  # Like {.parse} but raises on failure.
38
39
  #
39
40
  # @param input [String] Amount input, optionally including a currency symbol or code
40
- # @param currency [String, Symbol, Currency, nil] ISO code when not present in +input+
41
+ # @param currency [String, Currency, nil] default currency when none is present in +input+
42
+ # An embedded currency code or symbol takes precedence over this argument.
41
43
  # @return [Money]
42
44
  # @raise [ArgumentError] when +input+ is invalid or currency cannot be determined
43
45
  #
@@ -53,21 +55,28 @@ module Mint
53
55
  currency = parse_currency(input, currency)
54
56
  raise ArgumentError, "Currency [#{currency}] not found" unless currency
55
57
 
56
- amount = parse_amount(input)
58
+ amount = parse_amount(input, currency)
57
59
  raise ArgumentError, "Could not parse [#{input}]" unless amount
58
60
 
59
61
  amount = currency.normalize_amount(amount)
60
- new(amount, currency)
62
+ amount.zero? ? currency.zero : new(amount, currency)
61
63
  end
62
64
 
63
65
  class << self
64
66
  private
65
67
 
66
- # Extracts a numeric value from input that should only contain an amount.
67
- def parse_amount(input)
68
+ # Extracts one valid numeric value, allowing surrounding currency markers
69
+ # and uppercase annotation words (for example, "MAX 10.00 USD").
70
+ def parse_amount(input, currency)
68
71
  accounting_negative = input.start_with?('(') && input.end_with?(')')
72
+ return nil if (input.include?('(') || input.include?(')')) && !accounting_negative
73
+
74
+ numeric_input = accounting_negative ? input[1...-1] : input
75
+ numeric_input = remove_currency_markers(numeric_input, currency)
76
+ numeric_input = numeric_input.gsub(/\b[A-Z_]+\b/, ' ').delete('[]').strip
77
+ numeric_input.sub!(/\A([+-])\s+/, '\\1')
78
+ return nil unless numeric_input.match?(/\A[+-]?\d[\d.,]*\z/)
69
79
 
70
- numeric_input = input.gsub(/[^\d.,-]/, '')
71
80
  numeric = parse_separators(numeric_input)
72
81
  return nil unless numeric
73
82
 
@@ -75,6 +84,11 @@ module Mint
75
84
  accounting_negative ? -amount : amount
76
85
  end
77
86
 
87
+ def remove_currency_markers(input, currency)
88
+ markers = [currency.symbol, currency.disambiguate_symbol].compact.uniq
89
+ markers.reduce(input) { |result, marker| result.gsub(marker, ' ') }
90
+ end
91
+
78
92
  # Extracts currency from a string by matching ISO code or symbol.
79
93
  #
80
94
  # Scans all uppercase words and returns the first registered code, falling
@@ -95,6 +109,7 @@ module Mint
95
109
  # Converts locale-specific decimal/thousand separators into a plain decimal string.
96
110
  def parse_separators(numeric)
97
111
  return nil unless numeric.match?(/\d/)
112
+ return nil unless valid_numeric_syntax?(numeric)
98
113
 
99
114
  case classify_separators(numeric)
100
115
  when :decimal_period then numeric
@@ -122,6 +137,16 @@ module Mint
122
137
  else :thousands
123
138
  end
124
139
  end
140
+
141
+ def valid_numeric_syntax?(numeric)
142
+ unsigned = numeric.delete_prefix('-').delete_prefix('+')
143
+ unsigned.match?(/\A\d+\z/) ||
144
+ unsigned.match?(/\A\d+[.,]\d+\z/) ||
145
+ unsigned.match?(/\A\d+(?:,\d{3})+\.\d+\z/) ||
146
+ unsigned.match?(/\A\d+(?:\.\d{3})+,\d+\z/) ||
147
+ unsigned.match?(/\A\d+(?:,\d{3})+\z/) ||
148
+ unsigned.match?(/\A\d+(?:\.\d{3})+\z/)
149
+ end
125
150
  end
126
151
  end
127
152
  end
@@ -3,5 +3,5 @@
3
3
  # Root namespace for the Minting library.
4
4
  module Minting
5
5
  # Current version of the Minting gem.
6
- VERSION = '2.1.0'
6
+ VERSION = '2.2.0'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minting
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gilson Ferraz
@@ -36,6 +36,7 @@ files:
36
36
  - bin/check-currencies
37
37
  - bin/console
38
38
  - bin/setup
39
+ - doc/agents/AGENTS.md
39
40
  - doc/agents/api_review-2026-06-15.md
40
41
  - doc/agents/copilot-instructions.md
41
42
  - doc/agents/expired/AGENTS.md
@@ -43,6 +44,8 @@ files:
43
44
  - doc/agents/expired/gemini_gem_evaluation.md
44
45
  - doc/agents/expired/recommendations.md
45
46
  - doc/agents/expired/rubocop-issues.md
47
+ - doc/api_review-2026-08-13.md
48
+ - doc/security-report.md
46
49
  - lib/minting.rb
47
50
  - lib/minting/aliases.rb
48
51
  - lib/minting/currency/currency.rb
@@ -104,7 +107,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
104
107
  - !ruby/object:Gem::Version
105
108
  version: '0'
106
109
  requirements: []
107
- rubygems_version: 4.0.17
110
+ rubygems_version: 4.0.18
108
111
  specification_version: 4
109
112
  summary: Library to manipulate currency values
110
113
  test_files: []