minting 1.9.7 → 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 +234 -116
- 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 +81 -9
- 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/symbols.rb +37 -30
- data/lib/minting/mint/rounding.rb +9 -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 +22 -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 +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 +27 -0
- data/lib/minting/version.rb +1 -1
- data/lib/minting.rb +17 -8
- metadata +9 -29
- 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/money/format/formatting.rb +0 -130
|
@@ -9,11 +9,10 @@ module Mint
|
|
|
9
9
|
# @return [Money] the sum of the addition
|
|
10
10
|
# @raise [TypeError] if addition involves a different currency or incompatible types
|
|
11
11
|
def +(addend)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
end
|
|
12
|
+
return self if addend == 0
|
|
13
|
+
return copy_with(amount: amount + addend.amount) if addend.is_a?(Money) && currency == addend.currency
|
|
14
|
+
|
|
15
|
+
raise TypeError, "#{addend} can't be added to #{self}"
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
# Performs subtraction with another {Money} instance or standard zero Numeric.
|
|
@@ -22,10 +21,9 @@ module Mint
|
|
|
22
21
|
# @return [Money] the difference of the subtraction
|
|
23
22
|
# @raise [TypeError] if subtraction involves a different currency or incompatible types
|
|
24
23
|
def -(subtrahend)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
end
|
|
24
|
+
return self if subtrahend == 0
|
|
25
|
+
return copy_with(amount: amount - subtrahend.amount) if subtrahend.is_a?(Money) && currency == subtrahend.currency
|
|
26
|
+
|
|
29
27
|
raise TypeError, "#{subtrahend} can't be subtracted from #{self}"
|
|
30
28
|
end
|
|
31
29
|
|
|
@@ -52,10 +50,9 @@ module Mint
|
|
|
52
50
|
# @raise [TypeError] if divisor is of incompatible type or different currency
|
|
53
51
|
# @raise [ZeroDivisionError] if division by zero is attempted
|
|
54
52
|
def /(divisor)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
end
|
|
53
|
+
return copy_with(amount: amount / divisor) if divisor.is_a? Numeric
|
|
54
|
+
return amount / divisor.amount if divisor.is_a?(Money) && currency == divisor.currency
|
|
55
|
+
|
|
59
56
|
raise TypeError, "#{self} can't be divided by #{divisor}"
|
|
60
57
|
end
|
|
61
58
|
|
data/lib/minting/money/clamp.rb
CHANGED
|
@@ -26,19 +26,19 @@ module Mint
|
|
|
26
26
|
# if min is a Range, and max is not nil
|
|
27
27
|
#
|
|
28
28
|
# @example In range
|
|
29
|
-
#
|
|
29
|
+
# Money.from(5, 'USD').clamp(0, 10) #=> [USD 5.00] (returns self)
|
|
30
30
|
#
|
|
31
31
|
# @example Out of range, with Numeric bounds
|
|
32
|
-
#
|
|
32
|
+
# Money.from(50, 'USD').clamp(0, 10) #=> [USD 10.00]
|
|
33
33
|
#
|
|
34
34
|
# @example Out of range, with Money bounds
|
|
35
|
-
# loss =
|
|
36
|
-
# floor =
|
|
37
|
-
# ceil =
|
|
35
|
+
# loss = Money.from(-5, 'USD')
|
|
36
|
+
# floor = Money.from(0, 'USD')
|
|
37
|
+
# ceil = Money.from(10, 'USD')
|
|
38
38
|
# loss.clamp(floor, ceil) #=> [USD 0.00]
|
|
39
39
|
#
|
|
40
40
|
# @example Subunit-0 currency (JPY)
|
|
41
|
-
#
|
|
41
|
+
# Money.from(500, 'JPY').clamp(0, 100) #=> [JPY 100]
|
|
42
42
|
def clamp(min_or_range, max = nil)
|
|
43
43
|
if min_or_range.is_a?(Range)
|
|
44
44
|
raise(ArgumentError, "Either amount range alone or two amounts accepted: #{max}") if max
|
|
@@ -9,7 +9,7 @@ module Mint
|
|
|
9
9
|
# @param other [Numeric] the left-hand operand to coerce
|
|
10
10
|
# @return [Array(CoercedNumber, Money)] coerced operand array
|
|
11
11
|
# @example
|
|
12
|
-
# price =
|
|
12
|
+
# price = Money.from(10, 'USD')
|
|
13
13
|
# 5 * price #=> [USD 50.00] (via coercion)
|
|
14
14
|
def coerce(other)
|
|
15
15
|
[CoercedNumber.new(other), self]
|
|
@@ -25,12 +25,12 @@ module Mint
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# @example
|
|
28
|
-
# two_usd ==
|
|
28
|
+
# two_usd == Money.from(2r, 'USD') #=> [$ 2.00]
|
|
29
29
|
# two_usd > 0 #=> true
|
|
30
|
-
# two_usd >
|
|
30
|
+
# two_usd > Money.from(2, 'USD') #=> false
|
|
31
31
|
# two_usd > 1
|
|
32
32
|
# => TypeError: [$ 2.00] can't be compared to 1
|
|
33
|
-
# two_usd >
|
|
33
|
+
# two_usd > Money.from(2, 'BRL')
|
|
34
34
|
# => TypeError: [$ 2.00] can't be compared to [R$ 2.00]
|
|
35
35
|
#
|
|
36
36
|
def <=>(other)
|
|
@@ -29,34 +29,7 @@ module Mint
|
|
|
29
29
|
# Money.no_currency(100) #=> [XXX 100]
|
|
30
30
|
def self.no_currency(amount) = from(amount, 'XXX')
|
|
31
31
|
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# Returns +nil+ when the input is invalid or currency cannot be determined.
|
|
35
|
-
#
|
|
36
|
-
# @param input [String] Amount input, optionally including a currency symbol or code
|
|
37
|
-
# @param currency [String, Symbol, Currency, nil] ISO code when not present in +input+
|
|
38
|
-
# @return [Money, nil]
|
|
39
|
-
#
|
|
40
|
-
# @example With explicit currency
|
|
41
|
-
# Money.parse('19.99', 'USD') #=> [USD 19.99]
|
|
42
|
-
# Money.parse('garbage', 'USD') #=> nil
|
|
43
|
-
#
|
|
44
|
-
# @example With symbol or code in the string
|
|
45
|
-
# Money.parse('$19.99') #=> [USD 19.99]
|
|
46
|
-
# Money.parse('USD 1,234.56') #=> [USD 1234.56]
|
|
47
|
-
def self.parse(input, currency = nil) = Mint.parse(input, currency)
|
|
48
|
-
|
|
49
|
-
# Like {.parse} but raises on failure.
|
|
50
|
-
#
|
|
51
|
-
# @param input [String] Amount input, optionally including a currency symbol or code
|
|
52
|
-
# @param currency [String, Symbol, Currency, nil] ISO code when not present in +input+
|
|
53
|
-
# @return [Money]
|
|
54
|
-
# @raise [ArgumentError] when +input+ is invalid or currency cannot be determined
|
|
55
|
-
#
|
|
56
|
-
# @example
|
|
57
|
-
# Money.parse!('19.99', 'USD') #=> [USD 19.99]
|
|
58
|
-
# Money.parse!('garbage', 'USD') #=> ArgumentError
|
|
59
|
-
def self.parse!(input, currency = nil) = Mint.parse!(input, currency)
|
|
32
|
+
# Parsing is defined in +parse+. See {Money.parse} and {Money.parse!}.
|
|
60
33
|
|
|
61
34
|
# Returns a frozen zero Money in the given currency.
|
|
62
35
|
#
|
|
@@ -81,7 +54,7 @@ module Mint
|
|
|
81
54
|
# @example JPY (subunit 0)
|
|
82
55
|
# Money.from_subunits(1234, 'JPY') #=> [JPY 1234]
|
|
83
56
|
# @example Round trip
|
|
84
|
-
# m =
|
|
57
|
+
# m = Money.from(9.99, 'USD')
|
|
85
58
|
# Money.from_subunits(m.subunits, 'USD') == m #=> true
|
|
86
59
|
def self.from_subunits(subunits, currency)
|
|
87
60
|
raise ArgumentError, 'subunits must be an Integer' unless subunits.is_a?(Integer)
|
|
@@ -98,7 +71,7 @@ module Mint
|
|
|
98
71
|
# @param amount [Numeric] The new monetary amount
|
|
99
72
|
# @return [Money] A new Money object with the new amount, or self if the amount is unchanged
|
|
100
73
|
# @example
|
|
101
|
-
# price =
|
|
74
|
+
# price = Money.from(10.00, 'USD')
|
|
102
75
|
# price.copy_with(amount: 15.00) #=> [USD 15.00]
|
|
103
76
|
# price.copy_with(amount: 10.00) #=> [USD 10.00] (returns self)
|
|
104
77
|
def copy_with(amount:)
|
|
@@ -113,18 +86,6 @@ module Mint
|
|
|
113
86
|
end
|
|
114
87
|
end
|
|
115
88
|
|
|
116
|
-
# Returns a new Money with the given amount in the same currency.
|
|
117
|
-
#
|
|
118
|
-
# @deprecated Use {#copy_with} instead. Will be removed in v2.
|
|
119
|
-
# @param new_amount [Numeric] the new monetary amount
|
|
120
|
-
# @return [Money] a new Money instance, or self if unchanged
|
|
121
|
-
# @example
|
|
122
|
-
# Mint.money(10, 'USD').mint(15) #=> [USD 15.00]
|
|
123
|
-
def mint(new_amount)
|
|
124
|
-
warn 'Money#mint is now deprecated and will be removed in v2'
|
|
125
|
-
copy_with(amount: new_amount)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
89
|
private
|
|
129
90
|
|
|
130
91
|
# Initializes a new Money object with the given amount and currency.
|
|
@@ -11,7 +11,7 @@ module Mint
|
|
|
11
11
|
#
|
|
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
15
|
def to_d = amount.to_d 0
|
|
16
16
|
|
|
17
17
|
# Converts the monetary amount to a standard float.
|
|
@@ -27,36 +27,40 @@ module Mint
|
|
|
27
27
|
# @return [String] HTML5 `<data>` representation
|
|
28
28
|
def to_html(format = DEFAULT_FORMAT)
|
|
29
29
|
title = Kernel.format("#{currency_code} %0.#{currency.subunit}f", amount)
|
|
30
|
-
body = format(format
|
|
30
|
+
body = format(format)
|
|
31
31
|
%(<data class='money' title='#{title}'>#{ERB::Util.html_escape(body)}</data>)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# Truncates and converts the monetary amount to an Integer.
|
|
35
|
-
#
|
|
36
|
-
# @return [Integer] the integer representation of the money amount
|
|
37
|
-
# @example
|
|
38
|
-
# Mint.money(9.99, 'USD').to_i #=> 9
|
|
39
|
-
# Mint.money(-9.99, 'USD').to_i #=> -9
|
|
40
|
-
def to_i = amount.to_i
|
|
41
|
-
|
|
42
34
|
# Returns a Hash representation of the money instance.
|
|
43
35
|
#
|
|
44
36
|
# @return [Hash] hash with :currency (String) and :amount (String) keys
|
|
45
37
|
# @example
|
|
46
|
-
#
|
|
38
|
+
# Money.from(134120, 'BRL').to_hash
|
|
47
39
|
# #=> { currency: "BRL", amount: "134120.00" }
|
|
48
40
|
def to_hash
|
|
49
41
|
{ currency: currency_code, amount: Kernel.format("%0.#{currency.subunit}f", amount) }
|
|
50
42
|
end
|
|
51
43
|
|
|
52
|
-
#
|
|
53
|
-
# Highly optimized to run without external dependencies.
|
|
44
|
+
# Deserializes a Hash into a Money instance.
|
|
54
45
|
#
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
46
|
+
# Accepts both symbol and string keys, matching the output of {#to_hash}.
|
|
47
|
+
#
|
|
48
|
+
# @param hash [Hash] a hash with +:currency+ (or +"currency"+) and
|
|
49
|
+
# +:amount+ (or +"amount"+) keys
|
|
50
|
+
# @return [Money] the deserialized Money instance
|
|
51
|
+
# @raise [Mint::UnknownCurrency] if the currency can't be resolved
|
|
52
|
+
# @raise [ArgumentError] if amount is not parseable as a Rational
|
|
53
|
+
#
|
|
54
|
+
# @example
|
|
55
|
+
# Money.from_hash(currency: "USD", amount: "9.99")
|
|
56
|
+
# #=> [USD 9.99]
|
|
57
|
+
# @example Round-trip
|
|
58
|
+
# m = Money.from(134120, "BRL")
|
|
59
|
+
# Money.from_hash(m.to_hash) == m #=> true
|
|
60
|
+
def self.from_hash(hash)
|
|
61
|
+
currency = Currency.resolve!(hash[:currency] || hash['currency'])
|
|
62
|
+
amount = currency.normalize_amount(Rational(hash[:amount] || hash['amount']))
|
|
63
|
+
amount.zero? ? currency.zero : new(amount, currency)
|
|
60
64
|
end
|
|
61
65
|
|
|
62
66
|
# Returns the exact internal Rational representation of the monetary amount.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
# :nodoc:
|
|
5
|
+
class Money
|
|
6
|
+
# Formats money as a string with a customizable template, thousand delimiter,
|
|
7
|
+
# and decimal separator.
|
|
8
|
+
#
|
|
9
|
+
# @param template [String, Hash, nil] Either a format string with placeholders
|
|
10
|
+
# (%<symbol>s, %<amount>f, %<currency>s, %<integral>d, %<fractional>d, %<dsymbol>s),
|
|
11
|
+
# or a Hash with per-sign keys (:positive, :negative, :zero) each
|
|
12
|
+
# holding a format string. A Hash is convenient for sign-aware formats
|
|
13
|
+
# such as accounting parentheses:
|
|
14
|
+
#
|
|
15
|
+
# money.format({ negative: '(%<symbol>s%<amount>f)' })
|
|
16
|
+
#
|
|
17
|
+
# Missing keys fall back to the module default, so a Hash with only
|
|
18
|
+
# :negative will still format positives sensibly. The valid keys are
|
|
19
|
+
# :positive, :negative, :zero; anything else raises ArgumentError.
|
|
20
|
+
# When +nil+, falls back to +Mint.locale_backend+ if set, otherwise
|
|
21
|
+
# +"%<symbol>s%<amount>f"+.
|
|
22
|
+
# @param thousand [String, false, nil] Thousands delimiter (e.g., ',' for 1,000).
|
|
23
|
+
# When +nil+, falls back to +Mint.locale_backend+ if set, otherwise +","+.
|
|
24
|
+
# @param decimal [String, nil] Decimal separator (e.g., '.' or ',').
|
|
25
|
+
# When +nil+, falls back to +Mint.locale_backend+ if set, otherwise +"."+.
|
|
26
|
+
# @param locale [Symbol, String, nil] Locale key passed to the +Mint.locale_backend+
|
|
27
|
+
# callable when resolving locale-aware separators and format. Ignored when
|
|
28
|
+
# +Mint.locale_backend+ is a Hash or nil. Accepts symbols (+:en+, +:'pt-BR'+)
|
|
29
|
+
# and strings (+"pt-BR"+), passed through as-is (matching Rails +I18n.locale+
|
|
30
|
+
# convention).
|
|
31
|
+
# @return [String] Formatted money string
|
|
32
|
+
#
|
|
33
|
+
# @raise [ArgumentError] if +template+ is not a String or Hash, the Hash is
|
|
34
|
+
# empty, or the Hash contains an unrecognised key.
|
|
35
|
+
#
|
|
36
|
+
# @example Basic formatting
|
|
37
|
+
# money = Money.from(1234.56, 'USD')
|
|
38
|
+
# money.format #=> "$1,234.56"
|
|
39
|
+
# money.format(thousand: '.', decimal: ',') #=> "$1.234,56"
|
|
40
|
+
# money.format(decimal: ',', thousand: '') #=> "$1234,56"
|
|
41
|
+
#
|
|
42
|
+
# @example Custom templates
|
|
43
|
+
# money.format('%<amount>f') #=> "1234.56"
|
|
44
|
+
# money.format('%<currency>s %<amount>f') #=> "USD 1234.56"
|
|
45
|
+
# money.format('%<amount>f %<symbol>s') #=> "1234.56 $"
|
|
46
|
+
# money.format('%<symbol>s%<amount>+f') #=> "$+1234.56"
|
|
47
|
+
#
|
|
48
|
+
# @example Integral & fractional parts
|
|
49
|
+
# money.format('%<integral>d.%<fractional>02d') #=> "1234.56"
|
|
50
|
+
# price = Money.from(0.99, 'USD')
|
|
51
|
+
# price.format('%<integral>d dollars and %<fractional>02d cents')
|
|
52
|
+
# #=> "0 dollars and 99 cents"
|
|
53
|
+
#
|
|
54
|
+
# @example Per-sign Hash format (accounting parentheses)
|
|
55
|
+
# loss = Money.from(-1234.56, 'USD')
|
|
56
|
+
# loss.format({ negative: '(%<symbol>s%<amount>f)' }) #=> "($1,234.56)"
|
|
57
|
+
# Money.from(0, 'BRL').format({ zero: '--' }) #=> "--"
|
|
58
|
+
#
|
|
59
|
+
# @example Padding and alignment
|
|
60
|
+
# money.format('%<amount>10.2f') #=> " 1234.56"
|
|
61
|
+
# money.format('%<symbol>s%<amount>010.2f') #=> "$0001234.56"
|
|
62
|
+
#
|
|
63
|
+
# @example Locale-aware formatting (with Mint.locale_backend set)
|
|
64
|
+
# money.format # decimal and thousand come from locale_backend
|
|
65
|
+
# money.format(locale: :en) # locale passed to backend callable
|
|
66
|
+
# money.format(locale: 'pt-BR') # strings work too
|
|
67
|
+
#
|
|
68
|
+
def format(template = nil, decimal: nil, thousand: nil, width: nil, locale: nil)
|
|
69
|
+
template, decimal, thousand = resolve_format_options(template, decimal:, thousand:, locale:)
|
|
70
|
+
|
|
71
|
+
case template
|
|
72
|
+
when Hash # :noop - validated in Formatter.for
|
|
73
|
+
when String then template = { positive: template }
|
|
74
|
+
else raise ArgumentError, 'Invalid template. Only String or Hash are accepted'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
formatted = Formatter.for(template, decimal, thousand).format(self)
|
|
78
|
+
|
|
79
|
+
width ? formatted.rjust(width) : formatted
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Alias for {#format}. Takes the same arguments.
|
|
83
|
+
alias to_fs :format
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def resolve_format_options(template, decimal:, thousand:, locale:)
|
|
88
|
+
backend_opts = Mint.resolve_locale_for(locale:)
|
|
89
|
+
|
|
90
|
+
template ||= backend_opts[:format] || DEFAULT_FORMAT
|
|
91
|
+
decimal ||= backend_opts[:decimal] || '.'
|
|
92
|
+
if thousand.nil?
|
|
93
|
+
thousand = backend_opts[:thousand] || ','
|
|
94
|
+
thousand = false if thousand == decimal
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
[template, decimal, thousand]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -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
|
|
@@ -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
|