minting 1.9.7 → 2.1.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 +298 -122
- data/Rakefile +2 -7
- data/doc/agents/api_review-2026-06-15.md +1 -1
- data/doc/agents/copilot-instructions.md +2 -2
- 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 +77 -94
- data/lib/minting/currency/registry.rb +134 -0
- data/lib/minting/currency/rounding.rb +47 -0
- data/lib/minting/data/crypto-currencies.yaml +126 -0
- 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/registry.rb +9 -11
- data/lib/minting/mint/registry/symbols.rb +37 -30
- data/lib/minting/mint.rb +3 -2
- data/lib/minting/money/allocation/allocation.rb +2 -2
- data/lib/minting/money/allocation/split.rb +2 -2
- 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 +22 -18
- data/lib/minting/money/format/format.rb +100 -0
- data/lib/minting/money/format/formatter.rb +110 -0
- data/lib/minting/money/format/to_s.rb +20 -102
- data/lib/minting/money/format/validator.rb +34 -0
- data/lib/minting/money/money.rb +25 -9
- data/lib/minting/money/parse.rb +127 -0
- data/lib/minting/money/rounding.rb +26 -0
- data/lib/minting/version.rb +1 -1
- data/lib/minting.rb +17 -8
- metadata +12 -31
- data/doc/Mint/Currency.html +0 -2032
- data/doc/Mint/Money.html +0 -5139
- 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 -138
- data/doc/Mint.html +0 -931
- data/doc/Minting.html +0 -142
- data/doc/Numeric.html +0 -479
- data/doc/String.html +0 -241
- data/doc/_index.html +0 -206
- 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 -291
- data/doc/file_list.html +0 -59
- data/doc/frames.html +0 -22
- data/doc/index.html +0 -291
- 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 -758
- data/doc/top-level-namespace.html +0 -135
- 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/mint/rounding.rb +0 -65
- data/lib/minting/money/format/formatting.rb +0 -130
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
templates = @has_placeholder ? @templates_by_subunit[currency.subunit] : @templates
|
|
50
|
+
|
|
51
|
+
template = templates[amount <=> 0] || templates[1]
|
|
52
|
+
|
|
53
|
+
display_amount = @has_negative_template && amount < 0 ? -amount : amount
|
|
54
|
+
integral = display_amount.to_i
|
|
55
|
+
|
|
56
|
+
result = Kernel.format(template,
|
|
57
|
+
currency: currency.code,
|
|
58
|
+
dsymbol: @needs_dsymbol && currency.dsymbol,
|
|
59
|
+
symbol: currency.symbol,
|
|
60
|
+
amount: display_amount,
|
|
61
|
+
integral: integral,
|
|
62
|
+
fractional: @needs_fractional ? money.fractional.abs : 0)
|
|
63
|
+
apply_separators(result, integral)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def apply_separators(result, integral)
|
|
69
|
+
unsigned_integral = integral.abs
|
|
70
|
+
int_str = unsigned_integral.to_s
|
|
71
|
+
|
|
72
|
+
result.sub!("#{int_str}.", "#{int_str}#{@decimal}") if @decimal != '.'
|
|
73
|
+
|
|
74
|
+
if @needs_thousand_substitution && unsigned_integral >= 1000
|
|
75
|
+
formatted_int = int_str.gsub(THOUSAND_RE, @thousand_replacement)
|
|
76
|
+
result.gsub!(int_str, formatted_int)
|
|
77
|
+
end
|
|
78
|
+
result
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def compile
|
|
82
|
+
@templates = { -1 => @format[:negative], 0 => @format[:zero], 1 => @format[:positive] || Money::DEFAULT_FORMAT }
|
|
83
|
+
@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".
|
|
88
|
+
# The placeholder is later replaced with the actual subunit count at
|
|
89
|
+
# 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") }
|
|
91
|
+
@has_negative_template = @templates.key?(-1)
|
|
92
|
+
|
|
93
|
+
joined = @templates.values.join
|
|
94
|
+
@needs_fractional = joined.include?('%<fractional>')
|
|
95
|
+
@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
|
|
100
|
+
|
|
101
|
+
@has_placeholder = joined.include?(SUBUNIT_PLACEHOLDER)
|
|
102
|
+
return unless @has_placeholder
|
|
103
|
+
|
|
104
|
+
@templates_by_subunit = Hash.new do |h, subunit|
|
|
105
|
+
h[subunit] = @templates.transform_values { |f| f.gsub(SUBUNIT_PLACEHOLDER, subunit.to_s) }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -7,119 +7,37 @@ module Mint
|
|
|
7
7
|
# Uses `%<symbol>s` for the currency symbol and `%<amount>f` for the rounded amount.
|
|
8
8
|
DEFAULT_FORMAT = '%<symbol>s%<amount>f'
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
accounting: { format: { negative: '(%<symbol>s%<amount>f)' } },
|
|
13
|
-
european: { format: '%<amount>f %<symbol>s', decimal: ',', thousand: '.' },
|
|
14
|
-
currency: { format: '%<currency>s %<amount>f' }
|
|
15
|
-
}.freeze
|
|
10
|
+
# Match a digit followed by groups of 3 digits until end of string — inserts thousand separators.
|
|
11
|
+
THOUSAND_RE = /(\d)(?=(\d{3})+\z)/
|
|
16
12
|
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# @param preset [Symbol, nil] Named format preset, one of:
|
|
20
|
-
# +:accounting+, +:european+, +:amount+, +:currency+.
|
|
21
|
-
# When provided, expands to the preset's format options and merges
|
|
22
|
-
# with any explicit keyword arguments (kwargs override the preset).
|
|
23
|
-
# @param format [String, Hash, nil] Either a Format string with placeholders
|
|
24
|
-
# (%<symbol>s, %<amount>f, %<currency>s, %<integral>d, %<fractional>d, %<dsymbol>s),
|
|
25
|
-
# or a Hash with per-sign keys (:positive, :negative, :zero) each
|
|
26
|
-
# holding a format string. A Hash is convenient for sign-aware formats
|
|
27
|
-
# such as accounting parentheses:
|
|
28
|
-
#
|
|
29
|
-
# money.format(format: { negative: '(%<symbol>s%<amount>f)' })
|
|
30
|
-
#
|
|
31
|
-
# Missing keys fall back to the module default, so a Hash with only
|
|
32
|
-
# :negative will still format positives sensibly. The valid keys are
|
|
33
|
-
# :positive, :negative, :zero; anything else raises ArgumentError.
|
|
34
|
-
# When +nil+, falls back to +Mint.locale_backend+ if set, otherwise
|
|
35
|
-
# +"%<symbol>s%<amount>f"+.
|
|
36
|
-
# @param thousand [String, false, nil] Thousands delimiter (e.g., ',' for 1,000).
|
|
37
|
-
# When +nil+, falls back to +Mint.locale_backend+ if set, otherwise +","+.
|
|
38
|
-
# @param decimal [String, nil] Decimal separator (e.g., '.' or ',').
|
|
39
|
-
# When +nil+, falls back to +Mint.locale_backend+ if set, otherwise +"."+.
|
|
40
|
-
# @return [String] Formatted money string
|
|
41
|
-
#
|
|
42
|
-
# @raise [ArgumentError] if +preset+ is not a recognised name, or if
|
|
43
|
-
# +format+ is not a String or Hash, the Hash is empty, or the Hash
|
|
44
|
-
# contains an unrecognised key.
|
|
45
|
-
#
|
|
46
|
-
# @example Basic formatting
|
|
47
|
-
# money = Mint.money(1234.56, 'USD')
|
|
48
|
-
# money.format #=> "$1,234.56"
|
|
49
|
-
# money.format(thousand: '.', decimal: ',') #=> "$1.234,56"
|
|
50
|
-
# money.format(decimal: ',', thousand: '') #=> "$1234,56"
|
|
13
|
+
# Returns a string representation of the money amount.
|
|
51
14
|
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
# money.format(:amount) #=> "1234.56"
|
|
57
|
-
# money.format(:currency) #=> "USD 1234.56"
|
|
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.
|
|
58
19
|
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
# money.format(format: '%<currency>s %<amount>f') #=> "USD 1234.56"
|
|
62
|
-
# money.format(format: '%<amount>f %<symbol>s') #=> "1234.56 $"
|
|
63
|
-
# money.format(format: '%<symbol>s%<amount>+f') #=> "$+1234.56"
|
|
20
|
+
# Unlike {#format}, this method takes **no arguments** — use
|
|
21
|
+
# {#format} (alias {#to_fs}) for custom formatting.
|
|
64
22
|
#
|
|
65
|
-
# @
|
|
66
|
-
# money.format(format: '%<integral>d.%<fractional>02d') #=> "1234.56"
|
|
67
|
-
# price = Mint.money(0.99, 'USD')
|
|
68
|
-
# price.format(format: '%<integral>d dollars and %<fractional>02d cents')
|
|
69
|
-
# #=> "0 dollars and 99 cents"
|
|
23
|
+
# @return [String] formatted money string
|
|
70
24
|
#
|
|
71
|
-
# @example
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
# @example Padding and alignment
|
|
77
|
-
# money.format(format: '%<amount>10.2f') #=> " 1234.56"
|
|
78
|
-
# money.format(format: '%<symbol>s%<amount>010.2f') #=> "$0001234.56"
|
|
79
|
-
#
|
|
80
|
-
# @example Locale-aware formatting (with Mint.locale_backend set)
|
|
81
|
-
# money.format # decimal and thousand come from locale_backend
|
|
82
|
-
#
|
|
83
|
-
def format(preset = nil, format: nil, decimal: nil, thousand: nil, width: nil)
|
|
84
|
-
if preset
|
|
85
|
-
config = PRESETS.fetch(preset) { raise ArgumentError, "Unknown format preset: #{preset.inspect}" }
|
|
86
|
-
format ||= config[:format]
|
|
87
|
-
decimal ||= config[:decimal]
|
|
88
|
-
thousand ||= config[:thousand]
|
|
89
|
-
width ||= config[:width]
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
validate_separators!(decimal:, thousand:)
|
|
93
|
-
|
|
94
|
-
format, decimal, thousand = resolve_locale_for(format, decimal, thousand)
|
|
95
|
-
|
|
96
|
-
case format
|
|
97
|
-
when {}, '' then raise ArgumentError, 'format must not be empty'
|
|
98
|
-
when Hash then validate_format_hash(format)
|
|
99
|
-
when String then format = { positive: format }
|
|
100
|
-
else raise ArgumentError, 'Invalid format. Only String or Hash are accepted'
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
formatted = format_amount(format, decimal:, thousand:)
|
|
104
|
-
|
|
105
|
-
width ? formatted.rjust(width) : formatted
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
THOUSAND_RE = /(\d)(?=(\d{3})+\z)/
|
|
109
|
-
|
|
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"
|
|
110
29
|
def to_s
|
|
111
30
|
return format unless Mint.locale_backend.nil?
|
|
112
31
|
|
|
113
|
-
subunit
|
|
114
|
-
|
|
115
|
-
|
|
32
|
+
subunit = currency.subunit
|
|
33
|
+
major = integral.to_s
|
|
34
|
+
major.gsub!(THOUSAND_RE, '\1,') if amount.abs >= 1000
|
|
116
35
|
if subunit > 0
|
|
117
|
-
|
|
36
|
+
minor = fractional.abs.to_s.rjust(subunit, '0')
|
|
37
|
+
"#{currency.symbol}#{major}.#{minor}"
|
|
118
38
|
else
|
|
119
|
-
"#{currency.symbol}#{
|
|
39
|
+
"#{currency.symbol}#{major}"
|
|
120
40
|
end
|
|
121
41
|
end
|
|
122
|
-
|
|
123
|
-
alias to_fs :format
|
|
124
42
|
end
|
|
125
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,9 +8,13 @@ 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.
|
|
@@ -19,11 +23,14 @@ module Mint
|
|
|
19
23
|
class Money
|
|
20
24
|
attr_reader :amount, :currency
|
|
21
25
|
|
|
26
|
+
# Money::Currency is the canonical way to access Currency class
|
|
27
|
+
Currency = Mint::Currency
|
|
28
|
+
|
|
22
29
|
# Returns the ISO 3-letter currency code string.
|
|
23
30
|
#
|
|
24
31
|
# @return [String] the ISO currency code (e.g., "USD", "EUR", "BRL")
|
|
25
32
|
# @example
|
|
26
|
-
#
|
|
33
|
+
# Money.from(100, 'USD').currency_code #=> "USD"
|
|
27
34
|
def currency_code = currency.code
|
|
28
35
|
|
|
29
36
|
# Returns the monetary amount expressed in the currency's smallest unit (fractional units).
|
|
@@ -31,17 +38,26 @@ module Mint
|
|
|
31
38
|
#
|
|
32
39
|
# @return [Integer] the amount in fractional units
|
|
33
40
|
# @example
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
41
|
+
# Money.from(1234.56, 'USD').subunits #=> 123456
|
|
42
|
+
# Money.from(1000, 'JPY').subunits #=> 1000
|
|
43
|
+
# Money.from(123.456, 'IQD').subunits #=> 123456
|
|
37
44
|
def subunits = (amount * currency.fractional_multiplier).to_i
|
|
38
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
|
+
|
|
39
55
|
# Returns the fractional part of the amount.
|
|
40
56
|
# @example
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
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
|
|
45
61
|
|
|
46
62
|
# Generates a stable hash key for Money instances.
|
|
47
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,26 @@
|
|
|
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 activated on first call. Once activated,
|
|
13
|
+
# +Currency#normalize_amount+ dispatches through +Currency.rounding_mode+,
|
|
14
|
+
# adding ~10–35&ns of overhead to every money creation or mutation.
|
|
15
|
+
# When rounding modes are never used (the common case), the fast path
|
|
16
|
+
# incurs zero overhead.
|
|
17
|
+
#
|
|
18
|
+
# @param mode [Symbol] one of: +:up+, +:down+, +:even+
|
|
19
|
+
# @yield block to execute with the rounding mode active
|
|
20
|
+
# @raise [ArgumentError] if +mode+ is not a recognised rounding mode
|
|
21
|
+
def self.with_rounding(mode, &)
|
|
22
|
+
Currency.activate_custom_rounding!
|
|
23
|
+
Currency.rounding_mode(mode, &)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
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: 1.
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gilson Ferraz
|
|
@@ -36,17 +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/Money.html
|
|
42
|
-
- doc/Mint/RangeStepPatch.html
|
|
43
|
-
- doc/Mint/Registry.html
|
|
44
|
-
- doc/Mint/Rounding.html
|
|
45
|
-
- doc/Mint/UnknownCurrency.html
|
|
46
|
-
- doc/Minting.html
|
|
47
|
-
- doc/Numeric.html
|
|
48
|
-
- doc/String.html
|
|
49
|
-
- doc/_index.html
|
|
50
39
|
- doc/agents/api_review-2026-06-15.md
|
|
51
40
|
- doc/agents/copilot-instructions.md
|
|
52
41
|
- doc/agents/expired/AGENTS.md
|
|
@@ -54,36 +43,24 @@ files:
|
|
|
54
43
|
- doc/agents/expired/gemini_gem_evaluation.md
|
|
55
44
|
- doc/agents/expired/recommendations.md
|
|
56
45
|
- doc/agents/expired/rubocop-issues.md
|
|
57
|
-
- doc/class_list.html
|
|
58
|
-
- doc/css/common.css
|
|
59
|
-
- doc/css/full_list.css
|
|
60
|
-
- doc/css/style.css
|
|
61
|
-
- doc/file.README.html
|
|
62
|
-
- doc/file_list.html
|
|
63
|
-
- doc/frames.html
|
|
64
|
-
- doc/index.html
|
|
65
|
-
- doc/js/app.js
|
|
66
|
-
- doc/js/full_list.js
|
|
67
|
-
- doc/js/jquery.js
|
|
68
|
-
- doc/method_list.html
|
|
69
|
-
- doc/top-level-namespace.html
|
|
70
46
|
- lib/minting.rb
|
|
47
|
+
- lib/minting/aliases.rb
|
|
71
48
|
- lib/minting/currency/currency.rb
|
|
49
|
+
- lib/minting/currency/registry.rb
|
|
50
|
+
- lib/minting/currency/rounding.rb
|
|
51
|
+
- lib/minting/data/crypto-currencies.yaml
|
|
72
52
|
- lib/minting/data/world-currencies.yaml
|
|
73
53
|
- lib/minting/mint.rb
|
|
74
|
-
- lib/minting/mint/aliases.rb
|
|
75
54
|
- lib/minting/mint/dsl/numeric.rb
|
|
76
55
|
- lib/minting/mint/dsl/range.rb
|
|
77
56
|
- lib/minting/mint/dsl/string.rb
|
|
78
57
|
- lib/minting/mint/i18n.rb
|
|
79
58
|
- lib/minting/mint/mint.rb
|
|
80
|
-
- lib/minting/mint/
|
|
81
|
-
- lib/minting/mint/parser/separators.rb
|
|
59
|
+
- lib/minting/mint/registry/crypto.rb
|
|
82
60
|
- lib/minting/mint/registry/registration.rb
|
|
83
61
|
- lib/minting/mint/registry/registry.rb
|
|
84
62
|
- lib/minting/mint/registry/symbols.rb
|
|
85
63
|
- lib/minting/mint/registry/zeros.rb
|
|
86
|
-
- lib/minting/mint/rounding.rb
|
|
87
64
|
- lib/minting/money/allocation/allocation.rb
|
|
88
65
|
- lib/minting/money/allocation/split.rb
|
|
89
66
|
- lib/minting/money/arithmetics/methods.rb
|
|
@@ -93,9 +70,13 @@ files:
|
|
|
93
70
|
- lib/minting/money/comparable.rb
|
|
94
71
|
- lib/minting/money/constructors.rb
|
|
95
72
|
- lib/minting/money/conversion.rb
|
|
96
|
-
- lib/minting/money/format/
|
|
73
|
+
- lib/minting/money/format/format.rb
|
|
74
|
+
- lib/minting/money/format/formatter.rb
|
|
97
75
|
- lib/minting/money/format/to_s.rb
|
|
76
|
+
- lib/minting/money/format/validator.rb
|
|
98
77
|
- lib/minting/money/money.rb
|
|
78
|
+
- lib/minting/money/parse.rb
|
|
79
|
+
- lib/minting/money/rounding.rb
|
|
99
80
|
- lib/minting/version.rb
|
|
100
81
|
- minting.gemspec
|
|
101
82
|
homepage: https://github.com/gferraz/minting
|
|
@@ -123,7 +104,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
123
104
|
- !ruby/object:Gem::Version
|
|
124
105
|
version: '0'
|
|
125
106
|
requirements: []
|
|
126
|
-
rubygems_version: 4.0.
|
|
107
|
+
rubygems_version: 4.0.17
|
|
127
108
|
specification_version: 4
|
|
128
109
|
summary: Library to manipulate currency values
|
|
129
110
|
test_files: []
|