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.
- checksums.yaml +4 -4
- data/README.md +142 -40
- data/Rakefile +2 -7
- data/doc/agents/AGENTS.md +382 -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/currency.rb +1 -7
- data/lib/minting/currency/registry.rb +6 -5
- data/lib/minting/currency/rounding.rb +14 -1
- data/lib/minting/mint/dsl/numeric.rb +7 -2
- data/lib/minting/mint/dsl/string.rb +2 -2
- data/lib/minting/mint/mint.rb +6 -1
- data/lib/minting/mint/registry/crypto.rb +7 -3
- data/lib/minting/mint/registry/registration.rb +6 -2
- data/lib/minting/mint/registry/registry.rb +1 -1
- data/lib/minting/money/allocation/allocation.rb +6 -1
- data/lib/minting/money/clamp.rb +6 -16
- data/lib/minting/money/constructors.rb +1 -1
- data/lib/minting/money/conversion.rb +3 -0
- data/lib/minting/money/format/format.rb +10 -1
- data/lib/minting/money/format/formatter.rb +50 -22
- data/lib/minting/money/format/to_s.rb +4 -3
- data/lib/minting/money/money.rb +2 -1
- data/lib/minting/money/parse/separator_parser.rb +60 -0
- data/lib/minting/money/parse.rb +74 -14
- data/lib/minting/money/rounding.rb +4 -5
- data/lib/minting/version.rb +1 -1
- metadata +6 -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
|
|
@@ -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
|
-
|
|
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!
|
|
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,
|
|
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
|
-
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
|
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
|
|
@@ -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.
|
|
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?
|
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
|
|
@@ -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, %<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
33
|
-
@format = format
|
|
34
55
|
@decimal = decimal
|
|
35
|
-
|
|
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
|
-
|
|
72
|
+
cmp = amount <=> 0
|
|
73
|
+
template = templates[cmp] || templates[1]
|
|
74
|
+
sign = SIGNS[cmp]
|
|
52
75
|
|
|
53
|
-
|
|
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 =>
|
|
107
|
+
def compile(format:, thousand:)
|
|
108
|
+
@templates = { -1 => format[:negative], 0 => format[:zero], 1 => format[:positive] || Money::DEFAULT_FORMAT }
|
|
83
109
|
@templates.compact!
|
|
84
|
-
|
|
85
|
-
# precision
|
|
86
|
-
#
|
|
87
|
-
#
|
|
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!
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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/money.rb
CHANGED
|
@@ -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
|