minting 1.9.6 → 2.0.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 +244 -121
- data/Rakefile +17 -26
- data/bin/bench_check +2 -2
- data/doc/agents/api_review-2026-06-15.md +1 -1
- data/doc/agents/copilot-instructions.md +5 -5
- data/doc/agents/expired/copilot-instructions.md +2 -2
- data/doc/agents/expired/gemini_gem_evaluation.md +2 -2
- data/lib/minting/aliases.rb +22 -0
- data/lib/minting/currency/currency.rb +87 -12
- data/lib/minting/data/crypto-currencies.yaml +126 -0
- data/lib/minting/data/world-currencies.yaml +75 -23
- data/lib/minting/mint/i18n.rb +79 -29
- data/lib/minting/mint/mint.rb +1 -26
- data/lib/minting/mint/registry/crypto.rb +59 -0
- data/lib/minting/mint/registry/registration.rb +1 -2
- data/lib/minting/mint/registry/symbols.rb +37 -30
- data/lib/minting/mint/rounding.rb +10 -7
- data/lib/minting/mint.rb +1 -2
- data/lib/minting/money/allocation/allocation.rb +2 -2
- data/lib/minting/money/allocation/split.rb +1 -1
- data/lib/minting/money/arithmetics/operators.rb +10 -13
- data/lib/minting/money/clamp.rb +6 -6
- data/lib/minting/money/coercion.rb +1 -1
- data/lib/minting/money/comparable.rb +3 -3
- data/lib/minting/money/constructors.rb +3 -42
- data/lib/minting/money/conversion.rb +23 -18
- data/lib/minting/money/format/format.rb +100 -0
- data/lib/minting/money/format/formatter.rb +102 -0
- data/lib/minting/money/format/to_s.rb +34 -99
- data/lib/minting/money/format/validator.rb +34 -0
- data/lib/minting/money/money.rb +25 -13
- data/lib/minting/money/parse.rb +127 -0
- data/lib/minting/money/rounding.rb +27 -0
- data/lib/minting/version.rb +1 -1
- data/lib/minting.rb +17 -8
- metadata +9 -28
- data/doc/Mint/Currency.html +0 -1943
- data/doc/Mint/CurrencyRegistry.html +0 -511
- data/doc/Mint/Money.html +0 -4607
- data/doc/Mint/RangeStepPatch.html +0 -277
- data/doc/Mint/Registry.html +0 -863
- data/doc/Mint/Rounding.html +0 -506
- data/doc/Mint/UnknownCurrency.html +0 -136
- data/doc/Mint.html +0 -1004
- data/doc/Minting.html +0 -142
- data/doc/_index.html +0 -180
- data/doc/class_list.html +0 -54
- data/doc/css/common.css +0 -1
- data/doc/css/full_list.css +0 -206
- data/doc/css/style.css +0 -1089
- data/doc/file.README.html +0 -277
- data/doc/file_list.html +0 -59
- data/doc/frames.html +0 -22
- data/doc/index.html +0 -277
- data/doc/js/app.js +0 -801
- data/doc/js/full_list.js +0 -334
- data/doc/js/jquery.js +0 -4
- data/doc/method_list.html +0 -686
- data/doc/top-level-namespace.html +0 -151
- data/lib/minting/mint/aliases.rb +0 -16
- data/lib/minting/mint/parser/parser.rb +0 -97
- data/lib/minting/mint/parser/separators.rb +0 -41
- data/lib/minting/money/format/formatting.rb +0 -97
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
class Money
|
|
5
|
+
# Compiles and caches formatter lambdas for a fixed combination of format
|
|
6
|
+
# template, currency, and separator configuration.
|
|
7
|
+
#
|
|
8
|
+
# Use {.for} to obtain a cached instance; avoid +new+ directly unless you
|
|
9
|
+
# want an uncached formatter (typically only useful for testing).
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class Formatter
|
|
13
|
+
extend FormatterValidator
|
|
14
|
+
|
|
15
|
+
def self.cache = @cache ||= {}
|
|
16
|
+
|
|
17
|
+
# Returns a cached {Formatter} for the given configuration.
|
|
18
|
+
# @param format [Hash{Symbol => String}] per-sign templates
|
|
19
|
+
# @param decimal [String] decimal separator
|
|
20
|
+
# @param thousand [String, false] thousands delimiter (+false+ disables)
|
|
21
|
+
def self.for(format, decimal, thousand)
|
|
22
|
+
key = [format, decimal, thousand]
|
|
23
|
+
formatter = cache[key]
|
|
24
|
+
return formatter if formatter
|
|
25
|
+
|
|
26
|
+
validate_format!(format)
|
|
27
|
+
validate_separators!(decimal:, thousand:)
|
|
28
|
+
|
|
29
|
+
cache[key] = new(format, decimal, thousand)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(format, decimal, thousand)
|
|
33
|
+
@format = format
|
|
34
|
+
@decimal = decimal
|
|
35
|
+
@thousand = thousand
|
|
36
|
+
compile
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
SUBUNIT_PLACEHOLDER = "\uE000"
|
|
40
|
+
# Matches a digit followed by groups of exactly 3 digits that terminate
|
|
41
|
+
# at a non-digit or end-of-string. Used to insert thousand separators.
|
|
42
|
+
# e.g. "1234567" → "1" matches before "234" + "567" at string end.
|
|
43
|
+
THOUSAND_RE = /(\d)(?=(?:\d{3})+(?:[^\d]|$))/
|
|
44
|
+
|
|
45
|
+
def format(money)
|
|
46
|
+
amount = money.amount
|
|
47
|
+
currency = money.currency
|
|
48
|
+
|
|
49
|
+
template = @templates[amount <=> 0] || @positive_template
|
|
50
|
+
display_amount = template == @negative_template ? -amount : amount
|
|
51
|
+
integral = display_amount.to_i
|
|
52
|
+
|
|
53
|
+
template = template.gsub(SUBUNIT_PLACEHOLDER, currency.subunit.to_s) if @has_placeholder
|
|
54
|
+
result = Kernel.format(template,
|
|
55
|
+
currency: currency.code,
|
|
56
|
+
dsymbol: @needs_dsymbol && currency.dsymbol,
|
|
57
|
+
symbol: currency.symbol,
|
|
58
|
+
amount: display_amount,
|
|
59
|
+
integral: integral,
|
|
60
|
+
fractional: @needs_fractional ? money.fractional.abs : 0)
|
|
61
|
+
apply_separators(result, integral)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def apply_separators(result, integral)
|
|
67
|
+
unsigned_integral = integral.abs
|
|
68
|
+
int_str = unsigned_integral.to_s
|
|
69
|
+
|
|
70
|
+
result.sub!("#{int_str}.", "#{int_str}#{@decimal}") if @decimal != '.'
|
|
71
|
+
|
|
72
|
+
if @needs_thousand_substitution && unsigned_integral >= 1000
|
|
73
|
+
formatted_int = int_str.gsub(THOUSAND_RE, @thousand_replacement)
|
|
74
|
+
result.gsub!(int_str, formatted_int)
|
|
75
|
+
end
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def compile
|
|
80
|
+
@templates = { -1 => @format[:negative], 0 => @format[:zero], 1 => @format[:positive] || Money::DEFAULT_FORMAT }
|
|
81
|
+
@templates.compact!
|
|
82
|
+
# Inject subunit precision into %<amount>f specs that lack an explicit
|
|
83
|
+
# precision. Matches "%<amount>f" or "%+10<amount>f" (with optional
|
|
84
|
+
# flags/width before the named ref) and appends a placeholder for the
|
|
85
|
+
# currency subunit digits — e.g. "%<amount>f" → "%<amount>\uE000f".
|
|
86
|
+
# The placeholder is later replaced with the actual subunit count at
|
|
87
|
+
# format time (e.g. "\uE000" → "2" for USD, "0" for JPY).
|
|
88
|
+
@templates.transform_values! { |f| f.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{SUBUNIT_PLACEHOLDER}f") }
|
|
89
|
+
@negative_template = @templates[-1]
|
|
90
|
+
@positive_template = @templates[1]
|
|
91
|
+
|
|
92
|
+
joined_template = @templates.values.join
|
|
93
|
+
@has_placeholder = joined_template.include?(SUBUNIT_PLACEHOLDER)
|
|
94
|
+
@needs_fractional = joined_template.include?('%<fractional>')
|
|
95
|
+
@needs_dsymbol = joined_template.include?('%<dsymbol>')
|
|
96
|
+
@needs_thousand_substitution = @thousand && !@thousand.empty? && (joined_template.include?('%<amount>') ||
|
|
97
|
+
joined_template.include?('%<integral>'))
|
|
98
|
+
@thousand_replacement = "\\1#{@thousand}" if @needs_thousand_substitution
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -3,106 +3,41 @@
|
|
|
3
3
|
module Mint
|
|
4
4
|
# :nodoc:
|
|
5
5
|
class Money
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
# contains an unrecognised key.
|
|
41
|
-
#
|
|
42
|
-
# @example Basic formatting
|
|
43
|
-
# money = Mint.money(1234.56, 'USD')
|
|
44
|
-
# money.format #=> "$1,234.56"
|
|
45
|
-
# money.format(thousand: '.', decimal: ',') #=> "$1.234,56"
|
|
46
|
-
# money.format(decimal: ',', thousand: '') #=> "$1234,56"
|
|
47
|
-
#
|
|
48
|
-
# @example Preset formats
|
|
49
|
-
# loss = Mint.money(-1234.56, 'USD')
|
|
50
|
-
# loss.format(:accounting) #=> "($1,234.56)"
|
|
51
|
-
# money.format(:european) #=> "1.234,56 €"
|
|
52
|
-
# money.format(:amount) #=> "1234.56"
|
|
53
|
-
# money.format(:currency) #=> "USD 1234.56"
|
|
54
|
-
#
|
|
55
|
-
# @example Custom formats
|
|
56
|
-
# money.format(format: '%<amount>f') #=> "1234.56"
|
|
57
|
-
# money.format(format: '%<currency>s %<amount>f') #=> "USD 1234.56"
|
|
58
|
-
# money.format(format: '%<amount>f %<symbol>s') #=> "1234.56 $"
|
|
59
|
-
# money.format(format: '%<symbol>s%<amount>+f') #=> "$+1234.56"
|
|
60
|
-
#
|
|
61
|
-
# @example Integral & fractional parts
|
|
62
|
-
# money.format(format: '%<integral>d.%<fractional>02d') #=> "1234.56"
|
|
63
|
-
# price = Mint.money(0.99, 'USD')
|
|
64
|
-
# price.format(format: '%<integral>d dollars and %<fractional>02d cents')
|
|
65
|
-
# #=> "0 dollars and 99 cents"
|
|
66
|
-
#
|
|
67
|
-
# @example Per-sign Hash format (accounting parentheses)
|
|
68
|
-
# loss = Mint.money(-1234.56, 'USD')
|
|
69
|
-
# loss.format(format: { negative: '(%<symbol>s%<amount>f)' }) #=> "($1,234.56)"
|
|
70
|
-
# Mint.money(0, 'BRL').format(format: { zero: '--' }) #=> "--"
|
|
71
|
-
#
|
|
72
|
-
# @example Padding and alignment
|
|
73
|
-
# money.format(format: '%<amount>10.2f') #=> " 1234.56"
|
|
74
|
-
# money.format(format: '%<symbol>s%<amount>010.2f') #=> "$0001234.56"
|
|
75
|
-
#
|
|
76
|
-
# @example Locale-aware formatting (with Mint.locale_backend set)
|
|
77
|
-
# money.format # decimal and thousand come from locale_backend
|
|
78
|
-
#
|
|
79
|
-
def format(preset = nil, format: nil, decimal: nil, thousand: nil, width: nil)
|
|
80
|
-
if preset
|
|
81
|
-
config = PRESETS.fetch(preset) { raise ArgumentError, "Unknown format preset: #{preset.inspect}" }
|
|
82
|
-
format ||= config[:format]
|
|
83
|
-
decimal ||= config[:decimal]
|
|
84
|
-
thousand ||= config[:thousand]
|
|
85
|
-
width ||= config[:width]
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
validate_separators!(decimal:, thousand:)
|
|
89
|
-
|
|
90
|
-
format, decimal, thousand = resolve_locale_for(format, decimal, thousand)
|
|
91
|
-
|
|
92
|
-
case format
|
|
93
|
-
when {}, '' then raise ArgumentError, 'format must not be empty'
|
|
94
|
-
when Hash then validate_format_hash(format)
|
|
95
|
-
when String then format = { positive: format }
|
|
96
|
-
else raise ArgumentError, 'Invalid format. Only String or Hash are accepted'
|
|
6
|
+
# The default display format pattern for formatting monetary values.
|
|
7
|
+
# Uses `%<symbol>s` for the currency symbol and `%<amount>f` for the rounded amount.
|
|
8
|
+
DEFAULT_FORMAT = '%<symbol>s%<amount>f'
|
|
9
|
+
|
|
10
|
+
# Match a digit followed by groups of 3 digits until end of string — inserts thousand separators.
|
|
11
|
+
THOUSAND_RE = /(\d)(?=(\d{3})+\z)/
|
|
12
|
+
|
|
13
|
+
# Returns a string representation of the money amount.
|
|
14
|
+
#
|
|
15
|
+
# When no {Mint.locale_backend} is configured, uses +currency.symbol+,
|
|
16
|
+
# comma thousands separators for amounts >= 1000, and decimal for the
|
|
17
|
+
# fractional part. When a locale backend is set, delegates to {#format}
|
|
18
|
+
# so locale-aware formatting takes effect.
|
|
19
|
+
#
|
|
20
|
+
# Unlike {#format}, this method takes **no arguments** — use
|
|
21
|
+
# {#format} (alias {#to_fs}) for custom formatting.
|
|
22
|
+
#
|
|
23
|
+
# @return [String] formatted money string
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# Money.from(1234.56, 'USD').to_s #=> "$1,234.56"
|
|
27
|
+
# Money.from(0.99, 'USD').to_s #=> "$0.99"
|
|
28
|
+
# Money.from(100, 'JPY').to_s #=> "¥100"
|
|
29
|
+
def to_s
|
|
30
|
+
return format unless Mint.locale_backend.nil?
|
|
31
|
+
|
|
32
|
+
subunit = currency.subunit
|
|
33
|
+
major = integral.to_s
|
|
34
|
+
major.gsub!(THOUSAND_RE, '\1,') if amount.abs >= 1000
|
|
35
|
+
if subunit > 0
|
|
36
|
+
minor = fractional.abs.to_s.rjust(subunit, '0')
|
|
37
|
+
"#{currency.symbol}#{major}.#{minor}"
|
|
38
|
+
else
|
|
39
|
+
"#{currency.symbol}#{major}"
|
|
97
40
|
end
|
|
98
|
-
|
|
99
|
-
formatted = format_amount(format, decimal: decimal, thousand: thousand)
|
|
100
|
-
|
|
101
|
-
width ? formatted.rjust(width) : formatted
|
|
102
41
|
end
|
|
103
|
-
|
|
104
|
-
def to_s = format
|
|
105
|
-
|
|
106
|
-
alias to_fs :format
|
|
107
42
|
end
|
|
108
43
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
class Money
|
|
5
|
+
# Shared validation for format templates and separator configuration.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module FormatterValidator
|
|
9
|
+
def validate_format!(format)
|
|
10
|
+
raise ArgumentError, 'template must not be empty' if format == {}
|
|
11
|
+
|
|
12
|
+
unknown = format.keys - %i[positive negative zero]
|
|
13
|
+
raise ArgumentError, "Unknown format parameter(s): #{unknown.inspect}. " unless unknown.empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate_separators!(decimal:, thousand:)
|
|
17
|
+
case decimal
|
|
18
|
+
when '' then raise ArgumentError, "decimal separator must be a non-empty - #{decimal.inspect}"
|
|
19
|
+
when /\d/ then raise ArgumentError, "decimal separator cannot be a numeral - #{decimal.inspect}"
|
|
20
|
+
when thousand then raise ArgumentError, "decimal and thousand cannot be identical - #{decimal.inspect}"
|
|
21
|
+
when String # :noop
|
|
22
|
+
else raise ArgumentError, "decimal must be a String, false, or nil, got #{decimal.inspect}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
case thousand
|
|
26
|
+
when false, nil # :noop
|
|
27
|
+
when /\d/ then raise ArgumentError, "decimal separator cannot be a numeral - #{decimal.inspect}"
|
|
28
|
+
when String # :noop
|
|
29
|
+
else raise ArgumentError, "thousand must be a String, false, or nil, got #{thousand.inspect}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/minting/money/money.rb
CHANGED
|
@@ -8,26 +8,29 @@ require_relative 'clamp'
|
|
|
8
8
|
require_relative 'coercion'
|
|
9
9
|
require_relative 'comparable'
|
|
10
10
|
require_relative 'constructors'
|
|
11
|
+
require_relative 'parse'
|
|
11
12
|
require_relative 'conversion'
|
|
12
|
-
require_relative 'format/
|
|
13
|
+
require_relative 'format/validator'
|
|
14
|
+
require_relative 'format/formatter'
|
|
13
15
|
require_relative 'format/to_s'
|
|
16
|
+
require_relative 'format/format'
|
|
17
|
+
require_relative 'rounding'
|
|
14
18
|
|
|
15
19
|
module Mint
|
|
16
20
|
# Represents a monetary value paired with a currency.
|
|
17
21
|
# Money objects are immutable and support arithmetic, comparison,
|
|
18
22
|
# formatting, allocation, and parsing operations.
|
|
19
23
|
class Money
|
|
20
|
-
# The default display format pattern for formatting monetary values.
|
|
21
|
-
# Uses `%<symbol>s` for the currency symbol and `%<amount>f` for the rounded amount.
|
|
22
|
-
DEFAULT_FORMAT = '%<symbol>s%<amount>f'
|
|
23
|
-
|
|
24
24
|
attr_reader :amount, :currency
|
|
25
25
|
|
|
26
|
+
# Money::Currency is the canonical way to access Currency class
|
|
27
|
+
Currency = Mint::Currency
|
|
28
|
+
|
|
26
29
|
# Returns the ISO 3-letter currency code string.
|
|
27
30
|
#
|
|
28
31
|
# @return [String] the ISO currency code (e.g., "USD", "EUR", "BRL")
|
|
29
32
|
# @example
|
|
30
|
-
#
|
|
33
|
+
# Money.from(100, 'USD').currency_code #=> "USD"
|
|
31
34
|
def currency_code = currency.code
|
|
32
35
|
|
|
33
36
|
# Returns the monetary amount expressed in the currency's smallest unit (fractional units).
|
|
@@ -35,17 +38,26 @@ module Mint
|
|
|
35
38
|
#
|
|
36
39
|
# @return [Integer] the amount in fractional units
|
|
37
40
|
# @example
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
+
# Money.from(1234.56, 'USD').subunits #=> 123456
|
|
42
|
+
# Money.from(1000, 'JPY').subunits #=> 1000
|
|
43
|
+
# Money.from(123.456, 'IQD').subunits #=> 123456
|
|
41
44
|
def subunits = (amount * currency.fractional_multiplier).to_i
|
|
42
45
|
|
|
46
|
+
# Returns the whole-unit (integral) part of the amount.
|
|
47
|
+
# @example
|
|
48
|
+
# Money.from(1234.56, 'USD').integral #=> 1234
|
|
49
|
+
# Money.from(1000, 'JPY').integral #=> 1000
|
|
50
|
+
# Money.from(-9.99, 'USD').integral #=> -9
|
|
51
|
+
def integral = amount.to_i
|
|
52
|
+
|
|
53
|
+
alias to_i integral
|
|
54
|
+
|
|
43
55
|
# Returns the fractional part of the amount.
|
|
44
56
|
# @example
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
def fractional = ((amount
|
|
57
|
+
# Money.from(1234.56, 'USD').fractional #=> 56
|
|
58
|
+
# Money.from(1000, 'JPY').fractional #=> 0
|
|
59
|
+
# Money.from(123.456, 'IQD').fractional #=> 456
|
|
60
|
+
def fractional = ((amount - amount.to_i) * currency.fractional_multiplier).to_i
|
|
49
61
|
|
|
50
62
|
# Generates a stable hash key for Money instances.
|
|
51
63
|
#
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
# :nodoc:
|
|
5
|
+
class Money
|
|
6
|
+
# Parses a human-readable money string into a {Money} object.
|
|
7
|
+
#
|
|
8
|
+
# Returns +nil+ when the input is invalid or currency cannot be determined.
|
|
9
|
+
#
|
|
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+
|
|
12
|
+
# @return [Money, nil]
|
|
13
|
+
#
|
|
14
|
+
# @example With explicit currency
|
|
15
|
+
# Money.parse('19.99', 'USD') #=> [USD 19.99]
|
|
16
|
+
# Money.parse('garbage', 'USD') #=> nil
|
|
17
|
+
#
|
|
18
|
+
# @example With symbol or code in the string
|
|
19
|
+
# Money.parse('$19.99') #=> [USD 19.99]
|
|
20
|
+
# Money.parse('USD 1,234.56') #=> [USD 1234.56]
|
|
21
|
+
def self.parse(input, currency = nil)
|
|
22
|
+
return nil unless input.is_a?(String)
|
|
23
|
+
|
|
24
|
+
input = input.strip
|
|
25
|
+
return nil if input.empty?
|
|
26
|
+
|
|
27
|
+
currency = parse_currency(input, currency)
|
|
28
|
+
return nil unless currency
|
|
29
|
+
|
|
30
|
+
amount = parse_amount(input)
|
|
31
|
+
return nil unless amount
|
|
32
|
+
|
|
33
|
+
amount = currency.normalize_amount(amount)
|
|
34
|
+
new(amount, currency)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Like {.parse} but raises on failure.
|
|
38
|
+
#
|
|
39
|
+
# @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
|
+
# @return [Money]
|
|
42
|
+
# @raise [ArgumentError] when +input+ is invalid or currency cannot be determined
|
|
43
|
+
#
|
|
44
|
+
# @example
|
|
45
|
+
# Money.parse!('19.99', 'USD') #=> [USD 19.99]
|
|
46
|
+
# Money.parse!('garbage', 'USD') #=> ArgumentError
|
|
47
|
+
def self.parse!(input, currency = nil)
|
|
48
|
+
raise ArgumentError, 'input must be a String' unless input.is_a?(String)
|
|
49
|
+
|
|
50
|
+
input = input.strip
|
|
51
|
+
raise ArgumentError, 'input cannot be empty' if input.empty?
|
|
52
|
+
|
|
53
|
+
currency = parse_currency(input, currency)
|
|
54
|
+
raise ArgumentError, "Currency [#{currency}] not found" unless currency
|
|
55
|
+
|
|
56
|
+
amount = parse_amount(input)
|
|
57
|
+
raise ArgumentError, "Could not parse [#{input}]" unless amount
|
|
58
|
+
|
|
59
|
+
amount = currency.normalize_amount(amount)
|
|
60
|
+
new(amount, currency)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Extracts a numeric value from input that should only contain an amount.
|
|
67
|
+
def parse_amount(input)
|
|
68
|
+
accounting_negative = input.start_with?('(') && input.end_with?(')')
|
|
69
|
+
|
|
70
|
+
numeric_input = input.gsub(/[^\d.,-]/, '')
|
|
71
|
+
numeric = parse_separators(numeric_input)
|
|
72
|
+
return nil unless numeric
|
|
73
|
+
|
|
74
|
+
amount = Rational(numeric)
|
|
75
|
+
accounting_negative ? -amount : amount
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Extracts currency from a string by matching ISO code or symbol.
|
|
79
|
+
#
|
|
80
|
+
# Scans all uppercase words and returns the first registered code, falling
|
|
81
|
+
# back to symbol matching. This correctly handles inputs like
|
|
82
|
+
# "MAX 10.00 USD" where the first uppercase word isn't a currency code.
|
|
83
|
+
def parse_currency(input, currency = nil)
|
|
84
|
+
input.scan(/\b([A-Z_]+)\b/) do |(code)|
|
|
85
|
+
found = Currency.for_code(code)
|
|
86
|
+
return found if found
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
found = Registry.detect_currency(input)
|
|
90
|
+
return found if found
|
|
91
|
+
|
|
92
|
+
Currency.resolve(currency)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Converts locale-specific decimal/thousand separators into a plain decimal string.
|
|
96
|
+
def parse_separators(numeric)
|
|
97
|
+
return nil unless numeric.match?(/\d/)
|
|
98
|
+
|
|
99
|
+
case classify_separators(numeric)
|
|
100
|
+
when :decimal_period then numeric
|
|
101
|
+
when :decimal_comma then numeric.tr(',', '.')
|
|
102
|
+
when :thousands_comma then numeric.delete(',')
|
|
103
|
+
when :thousands then numeric.delete('.,')
|
|
104
|
+
when :invalid then nil
|
|
105
|
+
when :mixed
|
|
106
|
+
if numeric.rindex(',') > numeric.rindex('.')
|
|
107
|
+
numeric.delete('.').tr(',', '.')
|
|
108
|
+
else
|
|
109
|
+
numeric.delete(',')
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Classifies the separator pattern in a numeric string.
|
|
115
|
+
def classify_separators(numeric)
|
|
116
|
+
case [numeric.count('.'), numeric.count(',')]
|
|
117
|
+
in [0, 1] if numeric[-4] == ',' then :thousands_comma
|
|
118
|
+
in [0, 1] then :decimal_comma
|
|
119
|
+
in [0, 0] | [1, 0] then :decimal_period
|
|
120
|
+
in [p, c] if p > 1 && c > 1 then :invalid
|
|
121
|
+
in [p, c] if p > 0 && c > 0 then :mixed
|
|
122
|
+
else :thousands
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
# :nodoc:
|
|
5
|
+
class Money
|
|
6
|
+
# Executes a block with a specific rounding mode applied to all money
|
|
7
|
+
# construction, parsing, change, allocation, and split operations.
|
|
8
|
+
#
|
|
9
|
+
# Restores the previous mode (or default) when the block exits, even on
|
|
10
|
+
# exception.
|
|
11
|
+
#
|
|
12
|
+
# Rounding-mode support is loaded lazily on first call. Once loaded,
|
|
13
|
+
# +Currency#normalize_amount+ is patched to dispatch through the
|
|
14
|
+
# rounding module, adding ~10–35&ns of overhead to every money creation
|
|
15
|
+
# or mutation. When rounding modes are never used (the common case),
|
|
16
|
+
# the fast path incurs zero overhead.
|
|
17
|
+
#
|
|
18
|
+
# @param mode [Symbol] one of: +:half_up+, +:half_down+, +:floor+,
|
|
19
|
+
# +:ceil+, +:truncate+, +:down+
|
|
20
|
+
# @yield block to execute with the rounding mode active
|
|
21
|
+
# @raise [ArgumentError] if +mode+ is not a recognised rounding mode
|
|
22
|
+
def self.with_rounding(mode, &)
|
|
23
|
+
require_relative '../mint/rounding' unless defined?(Mint::Rounding)
|
|
24
|
+
Mint::Rounding.with_mode(mode, &)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/minting/version.rb
CHANGED
data/lib/minting.rb
CHANGED
|
@@ -3,12 +3,21 @@
|
|
|
3
3
|
require 'minting/mint'
|
|
4
4
|
require 'minting/version'
|
|
5
5
|
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
# @!parse
|
|
7
|
+
# # Top-level constant auto-bound to {Mint::Money} for convenience.
|
|
8
|
+
# Money = Mint::Money
|
|
9
|
+
|
|
10
|
+
# Top-level constant auto-bound to {Mint::Money} for convenience.
|
|
11
|
+
#
|
|
12
|
+
# Set at require-time via `require 'minting'`. If {::Money} is already
|
|
13
|
+
# defined (e.g. by the `money` gem), a warning is emitted and the existing
|
|
14
|
+
# constant is preserved — use {Mint::Money} explicitly in that case.
|
|
15
|
+
#
|
|
16
|
+
# @see Mint::Money
|
|
17
|
+
# @note This is a **breaking change from v1.x** where both +Money+ and
|
|
18
|
+
# +Currency+ required explicit opt-in via +Mint.use_top_level_constants!+.
|
|
19
|
+
Money = Mint::Money unless defined?(Money)
|
|
20
|
+
|
|
21
|
+
if Money != Mint::Money
|
|
22
|
+
warn "minting: top-level Money was already defined (#{Money}); skipping auto-bind! Use Mint::Money."
|
|
14
23
|
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:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gilson Ferraz
|
|
@@ -36,16 +36,6 @@ files:
|
|
|
36
36
|
- bin/check-currencies
|
|
37
37
|
- bin/console
|
|
38
38
|
- bin/setup
|
|
39
|
-
- doc/Mint.html
|
|
40
|
-
- doc/Mint/Currency.html
|
|
41
|
-
- doc/Mint/CurrencyRegistry.html
|
|
42
|
-
- doc/Mint/Money.html
|
|
43
|
-
- doc/Mint/RangeStepPatch.html
|
|
44
|
-
- doc/Mint/Registry.html
|
|
45
|
-
- doc/Mint/Rounding.html
|
|
46
|
-
- doc/Mint/UnknownCurrency.html
|
|
47
|
-
- doc/Minting.html
|
|
48
|
-
- doc/_index.html
|
|
49
39
|
- doc/agents/api_review-2026-06-15.md
|
|
50
40
|
- doc/agents/copilot-instructions.md
|
|
51
41
|
- doc/agents/expired/AGENTS.md
|
|
@@ -53,31 +43,18 @@ files:
|
|
|
53
43
|
- doc/agents/expired/gemini_gem_evaluation.md
|
|
54
44
|
- doc/agents/expired/recommendations.md
|
|
55
45
|
- doc/agents/expired/rubocop-issues.md
|
|
56
|
-
- doc/class_list.html
|
|
57
|
-
- doc/css/common.css
|
|
58
|
-
- doc/css/full_list.css
|
|
59
|
-
- doc/css/style.css
|
|
60
|
-
- doc/file.README.html
|
|
61
|
-
- doc/file_list.html
|
|
62
|
-
- doc/frames.html
|
|
63
|
-
- doc/index.html
|
|
64
|
-
- doc/js/app.js
|
|
65
|
-
- doc/js/full_list.js
|
|
66
|
-
- doc/js/jquery.js
|
|
67
|
-
- doc/method_list.html
|
|
68
|
-
- doc/top-level-namespace.html
|
|
69
46
|
- lib/minting.rb
|
|
47
|
+
- lib/minting/aliases.rb
|
|
70
48
|
- lib/minting/currency/currency.rb
|
|
49
|
+
- lib/minting/data/crypto-currencies.yaml
|
|
71
50
|
- lib/minting/data/world-currencies.yaml
|
|
72
51
|
- lib/minting/mint.rb
|
|
73
|
-
- lib/minting/mint/aliases.rb
|
|
74
52
|
- lib/minting/mint/dsl/numeric.rb
|
|
75
53
|
- lib/minting/mint/dsl/range.rb
|
|
76
54
|
- lib/minting/mint/dsl/string.rb
|
|
77
55
|
- lib/minting/mint/i18n.rb
|
|
78
56
|
- lib/minting/mint/mint.rb
|
|
79
|
-
- lib/minting/mint/
|
|
80
|
-
- lib/minting/mint/parser/separators.rb
|
|
57
|
+
- lib/minting/mint/registry/crypto.rb
|
|
81
58
|
- lib/minting/mint/registry/registration.rb
|
|
82
59
|
- lib/minting/mint/registry/registry.rb
|
|
83
60
|
- lib/minting/mint/registry/symbols.rb
|
|
@@ -92,9 +69,13 @@ files:
|
|
|
92
69
|
- lib/minting/money/comparable.rb
|
|
93
70
|
- lib/minting/money/constructors.rb
|
|
94
71
|
- lib/minting/money/conversion.rb
|
|
95
|
-
- lib/minting/money/format/
|
|
72
|
+
- lib/minting/money/format/format.rb
|
|
73
|
+
- lib/minting/money/format/formatter.rb
|
|
96
74
|
- lib/minting/money/format/to_s.rb
|
|
75
|
+
- lib/minting/money/format/validator.rb
|
|
97
76
|
- lib/minting/money/money.rb
|
|
77
|
+
- lib/minting/money/parse.rb
|
|
78
|
+
- lib/minting/money/rounding.rb
|
|
98
79
|
- lib/minting/version.rb
|
|
99
80
|
- minting.gemspec
|
|
100
81
|
homepage: https://github.com/gferraz/minting
|