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.
- checksums.yaml +4 -4
- data/README.md +114 -39
- data/Rakefile +2 -7
- data/doc/agents/AGENTS.md +376 -0
- data/doc/api_review-2026-08-13.md +144 -0
- data/doc/security-report.md +137 -0
- data/lib/minting/aliases.rb +8 -8
- data/lib/minting/currency/registry.rb +0 -3
- data/lib/minting/mint/dsl/numeric.rb +7 -2
- data/lib/minting/mint/dsl/string.rb +1 -1
- data/lib/minting/mint/mint.rb +6 -1
- data/lib/minting/mint/registry/crypto.rb +3 -1
- data/lib/minting/mint/registry/registry.rb +1 -1
- data/lib/minting/money/clamp.rb +6 -16
- data/lib/minting/money/constructors.rb +1 -1
- data/lib/minting/money/conversion.rb +1 -1
- data/lib/minting/money/format/formatter.rb +27 -5
- data/lib/minting/money/format/to_s.rb +4 -3
- data/lib/minting/money/parse.rb +34 -9
- data/lib/minting/version.rb +1 -1
- metadata +5 -2
data/lib/minting/aliases.rb
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# @!parse
|
|
4
|
-
# # Top-level alias for {
|
|
5
|
-
# 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 {
|
|
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 {
|
|
14
|
+
# constant is preserved — use {Mint::Currency} explicitly in that case.
|
|
15
15
|
#
|
|
16
|
-
# @see
|
|
16
|
+
# @see Mint::Currency
|
|
17
17
|
|
|
18
|
-
Currency =
|
|
18
|
+
Currency = Mint::Currency unless defined?(Currency)
|
|
19
19
|
|
|
20
|
-
if Currency !=
|
|
21
|
-
warn "minting: top-level Currency was already defined (#{Currency}); skipping alias. Use
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
data/lib/minting/mint/mint.rb
CHANGED
|
@@ -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)
|
|
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.
|
|
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.
|
|
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
|
data/lib/minting/money/clamp.rb
CHANGED
|
@@ -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,
|
|
22
|
-
# @param max [Money,
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/minting/money/parse.rb
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
67
|
-
|
|
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
|
data/lib/minting/version.rb
CHANGED
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.
|
|
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.
|
|
110
|
+
rubygems_version: 4.0.18
|
|
108
111
|
specification_version: 4
|
|
109
112
|
summary: Library to manipulate currency values
|
|
110
113
|
test_files: []
|