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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -116
  3. data/Rakefile +2 -7
  4. data/doc/agents/api_review-2026-06-15.md +1 -1
  5. data/doc/agents/copilot-instructions.md +2 -2
  6. data/doc/agents/expired/copilot-instructions.md +2 -2
  7. data/doc/agents/expired/gemini_gem_evaluation.md +2 -2
  8. data/lib/minting/aliases.rb +22 -0
  9. data/lib/minting/currency/currency.rb +81 -9
  10. data/lib/minting/data/crypto-currencies.yaml +126 -0
  11. data/lib/minting/mint/i18n.rb +79 -29
  12. data/lib/minting/mint/mint.rb +1 -26
  13. data/lib/minting/mint/registry/crypto.rb +59 -0
  14. data/lib/minting/mint/registry/registration.rb +1 -2
  15. data/lib/minting/mint/registry/symbols.rb +37 -30
  16. data/lib/minting/mint/rounding.rb +9 -7
  17. data/lib/minting/mint.rb +1 -2
  18. data/lib/minting/money/allocation/allocation.rb +2 -2
  19. data/lib/minting/money/allocation/split.rb +1 -1
  20. data/lib/minting/money/arithmetics/operators.rb +10 -13
  21. data/lib/minting/money/clamp.rb +6 -6
  22. data/lib/minting/money/coercion.rb +1 -1
  23. data/lib/minting/money/comparable.rb +3 -3
  24. data/lib/minting/money/constructors.rb +3 -42
  25. data/lib/minting/money/conversion.rb +22 -18
  26. data/lib/minting/money/format/format.rb +100 -0
  27. data/lib/minting/money/format/formatter.rb +102 -0
  28. data/lib/minting/money/format/to_s.rb +20 -102
  29. data/lib/minting/money/format/validator.rb +34 -0
  30. data/lib/minting/money/money.rb +25 -9
  31. data/lib/minting/money/parse.rb +127 -0
  32. data/lib/minting/money/rounding.rb +27 -0
  33. data/lib/minting/version.rb +1 -1
  34. data/lib/minting.rb +17 -8
  35. metadata +9 -29
  36. data/doc/Mint/Currency.html +0 -2032
  37. data/doc/Mint/Money.html +0 -5139
  38. data/doc/Mint/RangeStepPatch.html +0 -277
  39. data/doc/Mint/Registry.html +0 -863
  40. data/doc/Mint/Rounding.html +0 -506
  41. data/doc/Mint/UnknownCurrency.html +0 -138
  42. data/doc/Mint.html +0 -931
  43. data/doc/Minting.html +0 -142
  44. data/doc/Numeric.html +0 -479
  45. data/doc/String.html +0 -241
  46. data/doc/_index.html +0 -206
  47. data/doc/class_list.html +0 -54
  48. data/doc/css/common.css +0 -1
  49. data/doc/css/full_list.css +0 -206
  50. data/doc/css/style.css +0 -1089
  51. data/doc/file.README.html +0 -291
  52. data/doc/file_list.html +0 -59
  53. data/doc/frames.html +0 -22
  54. data/doc/index.html +0 -291
  55. data/doc/js/app.js +0 -801
  56. data/doc/js/full_list.js +0 -334
  57. data/doc/js/jquery.js +0 -4
  58. data/doc/method_list.html +0 -758
  59. data/doc/top-level-namespace.html +0 -135
  60. data/lib/minting/mint/aliases.rb +0 -16
  61. data/lib/minting/mint/parser/parser.rb +0 -97
  62. data/lib/minting/mint/parser/separators.rb +0 -41
  63. 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
- case addend
13
- in 0 then self
14
- in Money if same_currency?(addend) then copy_with(amount: amount + addend.amount)
15
- else raise TypeError, "#{addend} can't be added to #{self}"
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
- case subtrahend
26
- when 0 then return self
27
- when Money then return copy_with(amount: amount - subtrahend.amount) if same_currency?(subtrahend)
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
- case divisor
56
- when Numeric then return copy_with(amount: amount / divisor)
57
- when Money then return amount / divisor.amount if same_currency? divisor
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
 
@@ -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
- # Mint.money(5, 'USD').clamp(0, 10) #=> [USD 5.00] (returns self)
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
- # Mint.money(50, 'USD').clamp(0, 10) #=> [USD 10.00]
32
+ # Money.from(50, 'USD').clamp(0, 10) #=> [USD 10.00]
33
33
  #
34
34
  # @example Out of range, with Money bounds
35
- # loss = Mint.money(-5, 'USD')
36
- # floor = Mint.money(0, 'USD')
37
- # ceil = Mint.money(10, 'USD')
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
- # Mint.money(500, 'JPY').clamp(0, 100) #=> [JPY 100]
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 = Mint.money(10, 'USD')
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 == Mint.money(2r, 'USD') #=> [$ 2.00]
28
+ # two_usd == Money.from(2r, 'USD') #=> [$ 2.00]
29
29
  # two_usd > 0 #=> true
30
- # two_usd > Mint.money(2, 'USD') #=> false
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 > Mint.money(2, 'BRL')
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
- # Parses a human-readable money string into a {Money} object.
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 = Mint.money(9.99, 'USD')
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 = Mint.money(10.00, 'USD')
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
- # Mint.money(9.99, 'USD').to_d #=> 0.999e1
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: 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
- # Mint.money(134120, 'BRL').to_hash
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
- # Serializes the money instance to a standard JSON object containing the amount and currency.
53
- # Highly optimized to run without external dependencies.
44
+ # Deserializes a Hash into a Money instance.
54
45
  #
55
- # @return [String] the JSON serialized string representation
56
- def to_json(*_args)
57
- Kernel.format(
58
- %({"currency": "#{currency_code}", "amount": "%0.#{currency.subunit}f"}), amount
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
- PRESETS = {
11
- amount: { format: '%<amount>f' },
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
- # Formats money as a string with customizable format, thousand delimiter, and decimal
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
- # @example Preset formats
53
- # loss = Mint.money(-1234.56, 'USD')
54
- # loss.format(:accounting) #=> "($1,234.56)"
55
- # money.format(:european) #=> "1.234,56 €"
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
- # @example Custom formats
60
- # money.format(format: '%<amount>f') #=> "1234.56"
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
- # @example Integral & fractional parts
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 Per-sign Hash format (accounting parentheses)
72
- # loss = Mint.money(-1234.56, 'USD')
73
- # loss.format(format: { negative: '(%<symbol>s%<amount>f)' }) #=> "($1,234.56)"
74
- # Mint.money(0, 'BRL').format(format: { zero: '--' }) #=> "--"
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 = currency.subunit
114
- integral = to_i.to_s
115
- integral.gsub!(THOUSAND_RE, '\1,') if amount.abs >= 1000
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
- "#{currency.symbol}#{integral}.#{fractional.to_s.rjust(subunit, '0')}"
36
+ minor = fractional.abs.to_s.rjust(subunit, '0')
37
+ "#{currency.symbol}#{major}.#{minor}"
118
38
  else
119
- "#{currency.symbol}#{integral}"
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