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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +244 -121
  3. data/Rakefile +17 -26
  4. data/bin/bench_check +2 -2
  5. data/doc/agents/api_review-2026-06-15.md +1 -1
  6. data/doc/agents/copilot-instructions.md +5 -5
  7. data/doc/agents/expired/copilot-instructions.md +2 -2
  8. data/doc/agents/expired/gemini_gem_evaluation.md +2 -2
  9. data/lib/minting/aliases.rb +22 -0
  10. data/lib/minting/currency/currency.rb +87 -12
  11. data/lib/minting/data/crypto-currencies.yaml +126 -0
  12. data/lib/minting/data/world-currencies.yaml +75 -23
  13. data/lib/minting/mint/i18n.rb +79 -29
  14. data/lib/minting/mint/mint.rb +1 -26
  15. data/lib/minting/mint/registry/crypto.rb +59 -0
  16. data/lib/minting/mint/registry/registration.rb +1 -2
  17. data/lib/minting/mint/registry/symbols.rb +37 -30
  18. data/lib/minting/mint/rounding.rb +10 -7
  19. data/lib/minting/mint.rb +1 -2
  20. data/lib/minting/money/allocation/allocation.rb +2 -2
  21. data/lib/minting/money/allocation/split.rb +1 -1
  22. data/lib/minting/money/arithmetics/operators.rb +10 -13
  23. data/lib/minting/money/clamp.rb +6 -6
  24. data/lib/minting/money/coercion.rb +1 -1
  25. data/lib/minting/money/comparable.rb +3 -3
  26. data/lib/minting/money/constructors.rb +3 -42
  27. data/lib/minting/money/conversion.rb +23 -18
  28. data/lib/minting/money/format/format.rb +100 -0
  29. data/lib/minting/money/format/formatter.rb +102 -0
  30. data/lib/minting/money/format/to_s.rb +34 -99
  31. data/lib/minting/money/format/validator.rb +34 -0
  32. data/lib/minting/money/money.rb +25 -13
  33. data/lib/minting/money/parse.rb +127 -0
  34. data/lib/minting/money/rounding.rb +27 -0
  35. data/lib/minting/version.rb +1 -1
  36. data/lib/minting.rb +17 -8
  37. metadata +9 -28
  38. data/doc/Mint/Currency.html +0 -1943
  39. data/doc/Mint/CurrencyRegistry.html +0 -511
  40. data/doc/Mint/Money.html +0 -4607
  41. data/doc/Mint/RangeStepPatch.html +0 -277
  42. data/doc/Mint/Registry.html +0 -863
  43. data/doc/Mint/Rounding.html +0 -506
  44. data/doc/Mint/UnknownCurrency.html +0 -136
  45. data/doc/Mint.html +0 -1004
  46. data/doc/Minting.html +0 -142
  47. data/doc/_index.html +0 -180
  48. data/doc/class_list.html +0 -54
  49. data/doc/css/common.css +0 -1
  50. data/doc/css/full_list.css +0 -206
  51. data/doc/css/style.css +0 -1089
  52. data/doc/file.README.html +0 -277
  53. data/doc/file_list.html +0 -59
  54. data/doc/frames.html +0 -22
  55. data/doc/index.html +0 -277
  56. data/doc/js/app.js +0 -801
  57. data/doc/js/full_list.js +0 -334
  58. data/doc/js/jquery.js +0 -4
  59. data/doc/method_list.html +0 -686
  60. data/doc/top-level-namespace.html +0 -151
  61. data/lib/minting/mint/aliases.rb +0 -16
  62. data/lib/minting/mint/parser/parser.rb +0 -97
  63. data/lib/minting/mint/parser/separators.rb +0 -41
  64. data/lib/minting/money/format/formatting.rb +0 -97
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Crypto currency definitions (opt-in registration)
4
+ module Mint
5
+ # Internal registry for currencies, symbols, and zero-money cache.
6
+ module Registry
7
+ @crypto_currencies = nil
8
+ CRYPTO_MUTEX = Monitor.new
9
+
10
+ private_constant :CRYPTO_MUTEX
11
+
12
+ # Returns the list of built-in crypto currency definitions.
13
+ #
14
+ # These are not registered by default — call {.register_crypto} to opt in.
15
+ #
16
+ # @return [Array<Currency>] frozen array of crypto currency definitions
17
+ def self.crypto_currencies
18
+ @crypto_currencies || CRYPTO_MUTEX.synchronize do
19
+ @crypto_currencies ||= begin
20
+ path = File.join(File.expand_path('../../data', __dir__), 'crypto-currencies.yaml')
21
+ YAML.load_file(path).map { |entry| Currency.new(**entry.transform_keys(&:to_sym)) }.freeze
22
+ end
23
+ end
24
+ end
25
+
26
+ # Registers one or more crypto currencies into the shared currency registry.
27
+ #
28
+ # Raises on duplicate registration or unknown code — use +rescue+ if
29
+ # idempotent bulk registration is needed.
30
+ #
31
+ # @param codes [Array<String>] one or more crypto currency codes
32
+ # @raise [ArgumentError] if a code is not a known crypto currency
33
+ # @raise [KeyError] if the currency code is already registered
34
+ # @return [Array<Currency>] the newly registered Currency objects
35
+ def self.register_crypto(*codes)
36
+ entries = crypto_currencies
37
+ index = entries.each_with_index.to_h { |currency, index| [currency.code, index] }
38
+ missing = codes.reject { |code| index.key?(code) }
39
+ raise ArgumentError, "Unknown crypto code(s): #{missing.join(', ')}" unless missing.empty?
40
+
41
+ codes.map do |code|
42
+ c = entries[index[code]]
43
+ Currency.register(code:, subunit: c.subunit, symbol: c.symbol, priority: c.priority)
44
+ end
45
+ end
46
+
47
+ # Registers all built-in crypto currencies at once.
48
+ #
49
+ # Raises on the first duplicate — call +rescue+ if idempotency is needed.
50
+ #
51
+ # @raise [KeyError] if any currency code is already registered
52
+ # @return [Array<Currency>] the newly registered Currency objects
53
+ def self.register_all_crypto
54
+ crypto_currencies.map do |c|
55
+ Currency.register(code: c.code, subunit: c.subunit, symbol: c.symbol, priority: c.priority)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -24,8 +24,7 @@ module Mint
24
24
 
25
25
  currency = Currency.new(code:, subunit:, symbol:, priority:)
26
26
  @currencies = @currencies.merge(code => currency).freeze
27
- @currency_symbols = nil
28
- @currency_symbol_map = nil
27
+ @symbols_list = nil
29
28
  currency
30
29
  end
31
30
  end
@@ -5,44 +5,51 @@ module Mint
5
5
  module Registry
6
6
  extend self
7
7
 
8
- # Looks up a currency by its display symbol.
9
- #
10
- # @param symbol [String] the display symbol (e.g. "$", "R$")
11
- # @return [Currency, nil] the highest-priority currency for the symbol
12
- # @api private
13
8
  def currency_for_symbol(symbol)
14
- @currency_symbol_map || MUTEX.synchronize { @currency_symbol_map = currency_symbols.to_h.freeze }
15
- @currency_symbol_map[symbol]
9
+ sync_symbols
10
+ @symbols_map[symbol]
11
+ end
12
+
13
+ def symbol_shared?(symbol)
14
+ sync_symbols
15
+ @shared_symbols.include?(symbol)
16
16
  end
17
17
 
18
- # Scans +input+ for registered currency symbols and returns the first match.
19
- #
20
- # @param input [String] the string to scan
21
- # @return [Currency, nil]
22
- # @api private
23
18
  def detect_currency(input)
24
- currency_symbols.each do |symbol, currency|
25
- return currency if input.include?(symbol)
26
- end
27
- nil
19
+ sync_symbols
20
+ input.match(@symbols_regex) { |m| @symbols_map[m[0]] }
28
21
  end
29
22
 
30
23
  private
31
24
 
32
- # Registered symbols sorted for detection: longest match wins, then parser priority.
33
- # Duplicate symbols are deduplicated — the highest-priority currency wins.
34
- #
35
- # @return [Array<Array<String, Currency>>] sorted symbol-to-currency mappings
36
- # @api private
37
- def currency_symbols
38
- @currency_symbols || MUTEX.synchronize do
39
- @currency_symbols =
40
- currencies.values
41
- .reject { |currency| currency.symbol.empty? }
42
- .map { |currency| [currency.symbol, currency] }
43
- .sort_by { |symbol, currency| [-symbol.length, -currency.priority] }
44
- .uniq { |symbol, _| symbol }
45
- .freeze
25
+ def build_symbol_regex(symbols)
26
+ /(?<![a-zA-Z])#{Regexp.union(symbols)}(?![a-zA-Z])/
27
+ end
28
+
29
+ def sync_symbols
30
+ MUTEX.synchronize do
31
+ return if @symbols_list
32
+
33
+ symbols_list = []
34
+ currencies.each_value do |currency|
35
+ next unless currency.symbol
36
+
37
+ symbols_list << [currency.symbol, currency]
38
+ symbols_list << [currency.disambiguate_symbol, currency] if currency.disambiguate_symbol
39
+ end
40
+
41
+ @shared_symbols = symbols_list.map(&:first).tally
42
+ .select { |_, count| count > 1 }
43
+ .keys
44
+ .to_set
45
+ .freeze
46
+
47
+ symbols_list.sort_by! { |sym, cur| [-sym.length, -cur.priority] }
48
+ symbols_list.uniq! { |sym, _| sym }
49
+
50
+ @symbols_list = symbols_list.freeze
51
+ @symbols_map = symbols_list.to_h.freeze
52
+ @symbols_regex = build_symbol_regex(@symbols_map.keys)
46
53
  end
47
54
  end
48
55
  end
@@ -10,17 +10,20 @@ module Mint
10
10
  MODES = {
11
11
  half_up: ->(amount, ndigits) { amount.round(ndigits, half: :up) },
12
12
  half_down: ->(amount, ndigits) { amount.round(ndigits, half: :down) },
13
+ half_even: ->(amount, ndigits) { amount.round(ndigits, half: :even) },
13
14
  floor: ->(amount, ndigits) { amount.floor(ndigits) },
14
15
  ceil: ->(amount, ndigits) { amount.ceil(ndigits) },
15
16
  truncate: ->(amount, ndigits) { amount.truncate(ndigits) },
16
17
  down: ->(amount, ndigits) { amount.truncate(ndigits) }
17
18
  }.freeze
18
19
 
20
+ THREAD_KEY = :minting_rounding_mode
21
+
19
22
  # Returns the currently active rounding mode, falling back to +:half_up+.
20
23
  # @api private
21
24
  # @return [Symbol]
22
25
  def self.current_mode
23
- Thread.current[:minting_rounding_mode] || :half_up
26
+ Thread.current[THREAD_KEY] || :half_up
24
27
  end
25
28
 
26
29
  # Rounds +amount+ to +ndigits+ using the currently scoped rounding mode.
@@ -30,7 +33,7 @@ module Mint
30
33
  # @param ndigits [Integer]
31
34
  # @return [Rational]
32
35
  def self.apply(amount, ndigits)
33
- mode = Thread.current[:minting_rounding_mode]
36
+ mode = Thread.current[THREAD_KEY]
34
37
  if mode
35
38
  MODES.fetch(mode).call(amount.to_r, ndigits)
36
39
  else
@@ -47,18 +50,18 @@ module Mint
47
50
  def self.with_mode(mode)
48
51
  raise ArgumentError, "Unknown rounding mode: #{mode}" unless MODES.key?(mode)
49
52
 
50
- prev = Thread.current[:minting_rounding_mode]
51
- Thread.current[:minting_rounding_mode] = mode
53
+ prev = Thread.current[THREAD_KEY]
54
+ Thread.current[THREAD_KEY] = mode
52
55
  yield
53
56
  ensure
54
- Thread.current[:minting_rounding_mode] = prev
57
+ Thread.current[THREAD_KEY] = prev
55
58
  end
56
59
  end
57
60
  end
58
61
 
59
62
  # When loaded, patch Currency to delegate rounding through this module.
60
63
  # Remove the default fast-path method first to avoid redefinition warnings.
61
- Mint::Currency.remove_method(:normalize_amount)
62
- Mint::Currency.define_method(:normalize_amount) do |amount|
64
+ Money::Currency.remove_method(:normalize_amount)
65
+ Money::Currency.define_method(:normalize_amount) do |amount|
63
66
  Mint::Rounding.apply(amount, subunit)
64
67
  end
data/lib/minting/mint.rb CHANGED
@@ -7,8 +7,7 @@ require_relative 'mint/dsl/range'
7
7
  require_relative 'mint/dsl/string'
8
8
  require_relative 'mint/i18n'
9
9
  require_relative 'mint/mint'
10
- require_relative 'mint/parser/parser'
11
- require_relative 'mint/parser/separators'
12
10
  require_relative 'mint/registry/registry'
11
+ require_relative 'mint/registry/crypto'
13
12
 
14
13
  require_relative 'money/money'
@@ -10,14 +10,14 @@ module Mint
10
10
  # @raise [ArgumentError] if the proportions list is empty or sums to zero
11
11
  #
12
12
  # @example Proportional allocation
13
- # money = Mint.money(10.00, 'USD')
13
+ # money = Money.from(10.00, 'USD')
14
14
  # money.allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
15
15
  def allocate(proportions)
16
16
  whole = proportions.sum.to_r
17
17
  raise ArgumentError, 'Need at least 1 proportion element' if proportions.empty?
18
18
  raise ArgumentError, 'Proportions total must not be zero' if whole.zero?
19
19
 
20
- amounts = proportions.map { |rate| currency.normalize_amount(Rational(amount * rate, whole)) }
20
+ amounts = proportions.map { |rate| currency.normalize_amount(amount * rate / whole) }
21
21
  allocate_left_over(amounts: amounts, left_over: amount - amounts.sum)
22
22
  end
23
23
  end
@@ -12,7 +12,7 @@ module Mint
12
12
  # @raise [ArgumentError] if quantity is not a positive integer
13
13
  #
14
14
  # @example Even split
15
- # money = Mint.money(10.00, 'USD')
15
+ # money = Money.from(10.00, 'USD')
16
16
  # money.split(3) #=> [[USD 3.34], [USD 3.33], [USD 3.33]]
17
17
  def split(slices)
18
18
  raise ArgumentError, 'Slices quantity must be an poitive integer' unless slices.positive? && slices.integer?
@@ -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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bigdecimal'
4
+ require 'bigdecimal/util'
4
5
  require 'erb'
5
6
 
6
7
  module Mint
@@ -10,7 +11,7 @@ module Mint
10
11
  #
11
12
  # @return [BigDecimal] the decimal representation of the money amount
12
13
  # @example
13
- # Mint.money(9.99, 'USD').to_d #=> 0.999e1
14
+ # Money.from(9.99, 'USD').to_d #=> 0.999e1
14
15
  def to_d = amount.to_d 0
15
16
 
16
17
  # Converts the monetary amount to a standard float.
@@ -26,36 +27,40 @@ module Mint
26
27
  # @return [String] HTML5 `<data>` representation
27
28
  def to_html(format = DEFAULT_FORMAT)
28
29
  title = Kernel.format("#{currency_code} %0.#{currency.subunit}f", amount)
29
- body = format(format: format)
30
+ body = format(format)
30
31
  %(<data class='money' title='#{title}'>#{ERB::Util.html_escape(body)}</data>)
31
32
  end
32
33
 
33
- # Truncates and converts the monetary amount to an Integer.
34
- #
35
- # @return [Integer] the integer representation of the money amount
36
- # @example
37
- # Mint.money(9.99, 'USD').to_i #=> 9
38
- # Mint.money(-9.99, 'USD').to_i #=> -9
39
- def to_i = amount.to_i
40
-
41
34
  # Returns a Hash representation of the money instance.
42
35
  #
43
36
  # @return [Hash] hash with :currency (String) and :amount (String) keys
44
37
  # @example
45
- # Mint.money(134120, 'BRL').to_hash
38
+ # Money.from(134120, 'BRL').to_hash
46
39
  # #=> { currency: "BRL", amount: "134120.00" }
47
40
  def to_hash
48
41
  { currency: currency_code, amount: Kernel.format("%0.#{currency.subunit}f", amount) }
49
42
  end
50
43
 
51
- # Serializes the money instance to a standard JSON object containing the amount and currency.
52
- # Highly optimized to run without external dependencies.
44
+ # Deserializes a Hash into a Money instance.
53
45
  #
54
- # @return [String] the JSON serialized string representation
55
- def to_json(*_args)
56
- Kernel.format(
57
- %({"currency": "#{currency_code}", "amount": "%0.#{currency.subunit}f"}), amount
58
- )
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)
59
64
  end
60
65
 
61
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