minting 1.3.0 → 1.5.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.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mint
2
4
  # Represents a specific currency unit, identified by ISO 4217 alphabetic code
3
5
  #
@@ -8,25 +10,24 @@ module Mint
8
10
  :fractional_multiplier, :minimum_amount,
9
11
  :name, :priority
10
12
 
11
- def inspect
12
- "<Currency:(#{code} #{symbol} #{subunit})>"
13
- end
13
+ def inspect = "<Currency:(#{code} #{symbol} #{subunit} #{name})>"
14
14
 
15
- def normalize_amount(amount)
16
- amount.to_r.round(subunit)
17
- end
15
+ # Normalizes numeric amounts for this currency
16
+ # 1. Converts to Rational
17
+ # 2. Rounds to respect currency subunit
18
+ def normalize_amount(amount) = amount.to_r.round(subunit)
18
19
 
19
20
  private
20
21
 
21
22
  def initialize(code:, symbol:, subunit: 0, priority: 0, country: nil, name: nil)
22
- @code = code.to_s
23
+ @code = code
23
24
  @subunit = subunit.to_i
24
25
  @symbol = symbol
25
26
  @priority = priority.to_i
26
27
  @country = country
27
28
  @name = name
28
29
  @fractional_multiplier = 10**@subunit
29
- @minimum_amount = 1r / fractional_multiplier
30
+ @minimum_amount = Rational(1, fractional_multiplier)
30
31
  freeze
31
32
  end
32
33
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ # Mint currency store (internal)
6
+ module Mint
7
+ # Internal currency storage and loading.
8
+ # Manages the registry cache and currency symbol lookups.
9
+ module CurrencyStore
10
+ # Returns the hash of all registered currencies.
11
+ #
12
+ # @return [Hash{String => Currency}] registered currencies mapped by code
13
+ # @api private
14
+ def self.currencies
15
+ @currencies ||= begin
16
+ registry = { 'XXX' => Currency.new(code: 'XXX', name: 'No currency', symbol: '¤') }
17
+ load_currencies(registry)
18
+ end
19
+ end
20
+
21
+ # Registered symbols sorted for detection: longest match wins, then parser priority.
22
+ #
23
+ # @return [Array<Array<String, Currency>>] sorted symbol-to-currency mappings
24
+ # @api private
25
+ def self.currency_symbols
26
+ @currency_symbols ||= begin
27
+ currencies.values
28
+ .reject { |currency| currency.symbol.empty? }
29
+ .map { |currency| [currency.symbol, currency] }
30
+ .sort_by { |symbol, currency| [-symbol.length, -currency.priority] }
31
+ end.freeze
32
+ end
33
+
34
+ # Clears and refreshes the currency symbol cache.
35
+ # Called when currencies are registered.
36
+ #
37
+ # @api private
38
+ def self.invalidate_symbols_cache
39
+ @currency_symbols = nil
40
+ end
41
+
42
+ # Loads currencies from YAML file into the registry.
43
+ #
44
+ # @param registry [Hash] the registry hash to populate
45
+ # @return [Hash] the populated registry
46
+ # @api private
47
+ def self.load_currencies(registry)
48
+ base = File.expand_path('../data', __dir__)
49
+ path = File.join(base, 'currencies.yaml')
50
+
51
+ data = YAML.load_file(path)
52
+ data.each do |entry|
53
+ code = entry['code']
54
+ registry[code] = Currency.new(
55
+ code: code,
56
+ subunit: entry['subunit'],
57
+ symbol: entry['symbol'],
58
+ priority: entry['priority'],
59
+ country: entry['country'],
60
+ name: entry['name']
61
+ )
62
+ end
63
+ registry
64
+ end
65
+
66
+ private_class_method :load_currencies
67
+ end
68
+ end
@@ -1,4 +1,6 @@
1
- # Mint is a library to operate with monetary values
1
+ # frozen_string_literal: true
2
+
3
+ # Mint refinements
2
4
  module Mint
3
5
  refine Numeric do
4
6
  def reais
@@ -1,17 +1,18 @@
1
- require 'yaml'
1
+ # frozen_string_literal: true
2
2
 
3
+ # Mint currency registration and factory (public API)
3
4
  module Mint
4
5
  # Creates a new {Money} instance with the given amount and currency code.
5
6
  #
6
7
  # @param amount [Numeric] the financial value
7
- # @param currency_code [String, Symbol] the ISO currency code or symbol
8
+ # @param currency_code [String] the ISO currency code
8
9
  # @return [Money] the instantiated Money object
9
10
  # @raise [ArgumentError] if the currency code is not registered
10
11
  def self.money(amount, currency_code)
11
12
  currency = currency(currency_code)
12
13
  return Money.create(amount, currency) if currency
13
14
 
14
- raise ArgumentError, "[#{currency.inspect}] is not a registered currency. Check Mint.currencies"
15
+ raise ArgumentError, "[#{currency.inspect}] is not a registered currency."
15
16
  end
16
17
 
17
18
  # Returns default zero, no currency money
@@ -20,35 +21,27 @@ module Mint
20
21
  # Finds a registered currency by its code, symbol,
21
22
  # or retrieves it directly if already a Currency object.
22
23
  #
23
- # @param currency [String, Symbol, Currency] the currency identifier or object
24
+ # @param currency [String, Currency] the currency identifier or object
24
25
  # @return [Currency, nil] the registered Currency instance or nil if not found
25
26
  def self.currency(currency)
26
- case currency
27
- when Currency
28
- currency
29
- when Symbol
30
- currencies[currency.to_s]
31
- else
32
- currencies[currency]
33
- end
27
+ currency.is_a?(Currency) ? currency : CurrencyStore.currencies[currency]
34
28
  end
35
29
 
36
30
  # Registers a new currency if not already registered.
37
31
  #
38
- # @param code [String, Symbol] the unique currency code (e.g. 'USD', :EUR)
32
+ # @param code [String] the unique currency code (e.g. 'USD', 'EUR')
39
33
  # @param subunit [Integer] the decimal subunit precision (defaults to 2)
40
34
  # @param symbol [String] the display symbol (defaults to '')
41
35
  # @param priority [Integer] parser precedence priority (defaults to 0)
42
36
  # @return [Currency] the registered or existing Currency instance
43
37
  # @raise [ArgumentError] if the code layout is invalid or register throws an error
44
- def self.register_currency(code:, subunit: 2, symbol: '', priority: 0)
45
- code = code.to_s
46
- currencies[code] || register_currency!(code:, subunit:, symbol:, priority:)
38
+ def self.register_currency(code:, subunit: 0, symbol: '', priority: 0)
39
+ CurrencyStore.currencies[code] || register_currency!(code:, subunit:, symbol:, priority:)
47
40
  end
48
41
 
49
42
  # Strictly registers a new currency, raising a KeyError if already registered.
50
43
  #
51
- # @param code [String, Symbol] the unique currency code
44
+ # @param code [String] the unique currency code
52
45
  # @param subunit [Integer] the decimal subunit precision
53
46
  # @param symbol [String] the display symbol
54
47
  # @param priority [Integer] parser precedence priority
@@ -56,59 +49,26 @@ module Mint
56
49
  # @raise [ArgumentError] if the code contains invalid characters
57
50
  # @raise [KeyError] if the currency code is already registered
58
51
  def self.register_currency!(code:, subunit:, symbol: '', priority: 0)
59
- code = code.to_s
52
+ raise ArgumentError, 'Currency code must be String' unless code.is_a? String
60
53
  unless code.match?(/^[A-Z_]+$/)
61
54
  raise ArgumentError,
62
- "Currency code must be String or Symbol ('USD', :EUR, 'FUEL', 'MY_COIN')"
63
- end
64
- if currencies[code]
65
- raise KeyError,
66
- "Currency: #{code} already registered"
55
+ "Currency code must only letters or '_' ('USD',, 'MY_COIN')"
67
56
  end
68
57
 
58
+ currencies = CurrencyStore.currencies
59
+ raise KeyError, "Currency: #{code} already registered" if currencies[code]
60
+
69
61
  currency = currencies[code] = Currency.new(code:, subunit:, symbol:, priority:)
70
- @currency_symbols = nil
62
+ CurrencyStore.invalidate_symbols_cache
71
63
  currency
72
64
  end
73
65
 
74
- # Returns the hash of all registered currencies.
75
- #
76
- # @return [Hash{String => Currency}] registered currencies mapped by code
77
- def self.currencies
78
- @currencies ||= begin
79
- registry = { 'XXX' => Currency.new(code: 'XXX', name: 'No currency', symbol: '¤') }
80
- load_currencies(registry)
81
- end
82
- end
83
-
84
66
  # Registered symbols sorted for detection: longest match wins, then parser priority.
67
+ # Internal API - used by Money parser.
68
+ #
69
+ # @return [Array<Array<String, Currency>>] sorted symbol-to-currency mappings
70
+ # @api private
85
71
  def self.currency_symbols
86
- @currency_symbols ||= begin
87
- currencies.values
88
- .map { |currency| [currency.symbol, currency] }
89
- .reject { |symbol, _| symbol.empty? }
90
- .sort_by { |symbol, currency| [-symbol.length, -currency.priority] }
91
- end.freeze
92
- end
93
-
94
- def self.load_currencies(registry)
95
- base = File.expand_path('../data', __dir__)
96
- path = File.join(base, 'currencies.yaml')
97
-
98
- data = YAML.load_file(path)
99
- data.each do |entry|
100
- code = entry['code']
101
- registry[code] = Currency.new(
102
- code: code,
103
- subunit: entry['subunit'],
104
- symbol: entry['symbol'],
105
- priority: entry['priority'],
106
- country: entry['country'],
107
- name: entry['name']
108
- )
109
- end
110
- registry
72
+ CurrencyStore.currency_symbols
111
73
  end
112
-
113
- private_class_method :load_currencies
114
74
  end
data/lib/minting/mint.rb CHANGED
@@ -1,3 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'minting/mint/currency'
2
- require 'minting/mint/refinements'
4
+ require 'minting/mint/currency_store'
3
5
  require 'minting/mint/registry'
6
+ require 'minting/mint/refinements'
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mint
4
+ # Allocation and splitting
2
5
  class Money
3
6
  # Proportionally allocates the monetary amount among a list of ratios.
4
7
  # Disperses any subunit rounding amounts across the initial slots
@@ -50,7 +53,7 @@ module Mint
50
53
  last_slot = (left_over / minimum).to_i - 1
51
54
  (0..last_slot).each { |slot| amounts[slot] += minimum }
52
55
  end
53
- amounts.map { Money.new(it, currency) }
56
+ amounts.map { |amount| Money.new(amount, currency) }
54
57
  end
55
58
  end
56
59
  end
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mint
4
+ # Money Arithmetics
2
5
  class Money
3
6
  # Returns the absolute value of the monetary amount as a new {Money} instance.
4
7
  #
@@ -27,9 +30,10 @@ module Mint
27
30
  # @return [Money] the sum of the addition
28
31
  # @raise [TypeError] if addition involves a different currency or incompatible types
29
32
  def +(addend)
30
- return self if addend.respond_to?(:zero?) && addend.zero?
31
- return mint(amount + addend.amount) if addend.is_a?(Money) && same_currency?(addend)
32
-
33
+ case addend
34
+ when 0 then return self
35
+ when Money then return mint(amount + addend.amount) if same_currency?(addend)
36
+ end
33
37
  raise TypeError, "#{addend} can't be added to #{self}"
34
38
  end
35
39
 
@@ -39,11 +43,10 @@ module Mint
39
43
  # @return [Money] the difference of the subtraction
40
44
  # @raise [TypeError] if subtraction involves a different currency or incompatible types
41
45
  def -(subtrahend)
42
- return self if subtrahend.respond_to?(:zero?) && subtrahend.zero?
43
- if subtrahend.is_a?(Money) && same_currency?(subtrahend)
44
- return mint(amount - subtrahend.amount)
46
+ case subtrahend
47
+ when 0 then return self
48
+ when Money then return mint(amount - subtrahend.amount) if same_currency?(subtrahend)
45
49
  end
46
-
47
50
  raise TypeError, "#{subtrahend} can't be subtracted from #{self}"
48
51
  end
49
52
 
@@ -72,10 +75,22 @@ module Mint
72
75
  # @raise [TypeError] if divisor is of incompatible type or different currency
73
76
  # @raise [ZeroDivisionError] if division by zero is attempted
74
77
  def /(divisor)
75
- return mint(amount / divisor) if divisor.is_a?(Numeric)
76
- return amount / divisor.amount if same_currency? divisor
77
-
78
+ case divisor
79
+ when Numeric then return mint(amount / divisor)
80
+ when Money then return amount / divisor.amount if same_currency? divisor
81
+ end
78
82
  raise TypeError, "#{self} can't be divided by #{divisor}"
79
83
  end
84
+
85
+ # Performs exponentiation of the monetary value by a standard scalar Numeric.
86
+ #
87
+ # @param exponent [Numeric]
88
+ # @return [Money] reult of amount ** exponent
89
+ # @raise [TypeError] if exponent is not Numeric
90
+ def **(exponent)
91
+ return mint(amount**exponent) if exponent.is_a?(Numeric)
92
+
93
+ raise TypeError, "#{self} can't be powered by #{exponent}"
94
+ end
80
95
  end
81
96
  end
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mint
4
+ # Implements the standard Ruby coercion protocol.
2
5
  class Money
3
- # Implements the standard Ruby coercion protocol.
4
6
  # Allows {Money} to interact seamlessly as the right-hand operand in Numeric arithmetic.
5
7
  #
6
8
  # @param other [Numeric] the left-hand operand to coerce
@@ -1,5 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mint
2
- # :nodoc
3
4
  # Comparison methods
4
5
  class Money
5
6
  include Comparable
@@ -28,12 +29,10 @@ module Mint
28
29
  #
29
30
  def <=>(other)
30
31
  case other
31
- when Numeric
32
- return amount <=> other if other.zero?
33
- when Mint::Money
34
- return amount <=> other.amount if currency == other.currency
32
+ in 0 then amount <=> other
33
+ in Mint::Money if same_currency?(other) then amount <=> other.amount
34
+ else raise TypeError, "#{inspect} can't be compared to #{other.inspect}"
35
35
  end
36
- raise TypeError, "#{inspect} can't be compared to #{other.inspect}"
37
36
  end
38
37
 
39
38
  def nonzero? = amount.nonzero?
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mint
4
+ # Money constructors
5
+ class Money
6
+ # Creates a new Money immutable object with the specified amount and currency
7
+ # @param amount [Numeric] The monetary amount
8
+ # @param currency [Currency] The currency object
9
+ # @raise [ArgumentError] If amount is not numeric or currency is invalid
10
+ def self.create(amount, currency)
11
+ raise ArgumentError, 'amount must be Numeric' unless amount.is_a?(Numeric)
12
+
13
+ checked_currency = Mint.currency(currency)
14
+ raise ArgumentError, "Currency not found (#{currency})" unless checked_currency
15
+
16
+ new(checked_currency.normalize_amount(amount), checked_currency)
17
+ end
18
+
19
+ # Builds a Money from a fractional (smallest-unit) Integer amount.
20
+ # This is the inverse of {#fractional}: for USD, the fractional unit is
21
+ # 1 cent; for JPY it is 1 yen; for IQD it is 1 dinar (subunit 3).
22
+ #
23
+ # @param fractional [Integer] the amount expressed in the currency's
24
+ # smallest unit (e.g. cents). Must be an Integer to preserve exactness.
25
+ # @param currency [String, Symbol, Currency] the currency identifier
26
+ # @return [Money] the resulting Money instance
27
+ # @raise [ArgumentError] if +fractional+ is not an Integer or +currency+
28
+ # is not registered
29
+ #
30
+ # @example USD cents
31
+ # Money.from_fractional(123_456, 'USD') #=> [USD 1234.56]
32
+ # @example JPY (subunit 0)
33
+ # Money.from_fractional(1234, 'JPY') #=> [JPY 1234]
34
+ # @example Round trip
35
+ # m = Mint.money(9.99, 'USD')
36
+ # Money.from_fractional(m.fractional, 'USD') == m #=> true
37
+ def self.from_fractional(fractional, currency)
38
+ raise ArgumentError, 'fractional must be an Integer' unless fractional.is_a?(Integer)
39
+
40
+ checked_currency = Mint.currency(currency)
41
+ raise ArgumentError, "Currency not found (#{currency})" unless checked_currency
42
+
43
+ amount = Rational(fractional, checked_currency.fractional_multiplier)
44
+ new(amount, checked_currency)
45
+ end
46
+
47
+ # Returns a new Money object with the specified amount, or self if unchanged
48
+ # @param new_amount [Numeric] The new amount
49
+ # @return [Money] A new Money object or self
50
+ def mint(new_amount)
51
+ new_amount = currency.normalize_amount(new_amount)
52
+ new_amount == amount ? self : Money.new(new_amount, currency)
53
+ end
54
+
55
+ private
56
+
57
+ # Initializes a new Money object with the given amount and currency.
58
+ # @param amount [Numeric] The monetary amount
59
+ # @param currency [Currency] The currency object
60
+ def initialize(amount, currency)
61
+ @amount = amount
62
+ @currency = currency
63
+ freeze
64
+ end
65
+ end
66
+ end
@@ -1,25 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
1
4
  require 'erb'
2
5
 
3
6
  module Mint
4
7
  # Conversion and serialization logic for {Money} instances.
5
8
  class Money
6
- # Converts the monetary amount to a {BigDecimal} object.
9
+ # Converts the monetary amount to a BigDecimal object.
7
10
  #
8
11
  # @return [BigDecimal] the decimal representation of the money amount
9
- # @raise [NoMethodError] if the bigdecimal gem is not loaded/available
10
- def to_d
11
- raise NoMethodError, 'decimal gem required' unless defined?(BigDecimal)
12
-
13
- amount.to_d 0
14
- end
12
+ def to_d = amount.to_d 0
15
13
 
16
14
  # Converts the monetary amount to a standard float.
17
15
  # Note: Using float conversion loses precision guarantees.
18
16
  #
19
17
  # @return [Float] the floating-point representation of the money amount
20
- def to_f
21
- amount.to_f
22
- end
18
+ def to_f = amount.to_f
23
19
 
24
20
  # Renders a safe HTML5 `<data>` element containing the formatted currency.
25
21
  # Embeds the ISO currency description and raw value as the metadata `title` attribute.
@@ -29,15 +25,13 @@ module Mint
29
25
  def to_html(format = DEFAULT_FORMAT)
30
26
  title = Kernel.format("#{currency_code} %0.#{currency.subunit}f", amount)
31
27
  body = to_s(format: format)
32
- %(<data class='money' title='#{ERB::Util.html_escape(title)}'>#{ERB::Util.html_escape(body)}</data>)
28
+ %(<data class='money' title='#{title}'>#{ERB::Util.html_escape(body)}</data>)
33
29
  end
34
30
 
35
31
  # Truncates and converts the monetary amount to an Integer.
36
32
  #
37
33
  # @return [Integer] the integer representation of the money amount
38
- def to_i
39
- amount.to_i
40
- end
34
+ def to_i = amount.to_i
41
35
 
42
36
  def to_hash
43
37
  { currency: currency_code, amount: Kernel.format("%0.#{currency.subunit}f", amount) }
@@ -56,8 +50,6 @@ module Mint
56
50
  # Returns the exact internal Rational representation of the monetary amount.
57
51
  #
58
52
  # @return [Rational] the rational representation of the money amount
59
- def to_r
60
- amount
61
- end
53
+ def to_r = amount
62
54
  end
63
55
  end
@@ -1,13 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mint
2
4
  # Formatting functionality for Money objects
3
5
  class Money
4
6
  # Formats money as a string with customizable format, thousand delimiter, and decimal
5
7
  #
6
- # @param format [String] Format string with placeholders: %<symbol>s, %<amount>f, %<currency>s
8
+ # @param format [String, Hash] Either a Format string with placeholders
9
+ # (%<symbol>s, %<amount>f, %<currency>s), or a Hash with per-sign keys
10
+ # (:positive, :negative, :zero) each holding a format string. A Hash
11
+ # is convenient for sign-aware formats such as accounting parentheses:
12
+ #
13
+ # money.to_s(format: { negative: '(%<symbol>s%<amount>f)' })
14
+ #
15
+ # Missing keys fall back to the module default, so a Hash with only
16
+ # :negative will still format positives sensibly. The valid keys are
17
+ # :positive, :negative, :zero; anything else raises ArgumentError.
7
18
  # @param thousand [String, false] Thousands delimiter (e.g., ',' for 1,000)
8
19
  # @param decimal [String] Decimal separator (e.g., '.' or ',')
9
20
  # @return [String] Formatted money string
10
21
  #
22
+ # @raise [ArgumentError] if +format+ is not a String or Hash, the Hash
23
+ # is empty, or the Hash contains an unrecognised key.
24
+ #
11
25
  # @example Basic formatting
12
26
  # money = Mint.money(1234.56, 'USD')
13
27
  # money.to_s #=> "$1,234.56"
@@ -20,12 +34,22 @@ module Mint
20
34
  # money.to_s(format: '%<amount>f %<symbol>s') #=> "1234.56 $"
21
35
  # money.to_s(format: '%<symbol>s%<amount>+f') #=> "$+1234.56"
22
36
  #
37
+ # @example Per-sign Hash format (accounting parentheses)
38
+ # loss = Mint.money(-1234.56, 'USD')
39
+ # loss.to_s(format: { negative: '(%<symbol>s%<amount>f)' }) #=> "($1,234.56)"
40
+ # Mint.money(0, 'BRL').to_s(format: { zero: '--' }) #=> "--"
41
+ #
23
42
  # @example Padding and alignment
24
43
  # money.to_s(format: '%<amount>10.2f') #=> " 1234.56"
25
44
  # money.to_s(format: '%<symbol>s%<amount>010.2f') #=> "$0001234.56"
26
45
  #
27
46
  def to_s(format: '%<symbol>s%<amount>f', decimal: '.', thousand: ',', width: nil)
28
- raise ArgumentError, 'Invalid format' unless format.is_a?(String) || format.is_a?(Hash)
47
+ case format
48
+ when {}, '', nil then raise ArgumentError, 'format must not be empty or null'
49
+ when Hash then validate_format_hash!(format)
50
+ when String # noop
51
+ else raise ArgumentError, 'Invalid format'
52
+ end
29
53
 
30
54
  formatted = format_amount(format)
31
55
 
@@ -43,17 +67,27 @@ module Mint
43
67
 
44
68
  private
45
69
 
70
+ def validate_format_hash!(format)
71
+ unknown = format.keys - %i[positive negative zero]
72
+
73
+ raise ArgumentError, "Unknown format parameter(s): #{unknown.inspect}. " unless unknown.empty?
74
+ end
75
+
46
76
  def format_amount(format)
47
77
  format = { positive: format } if format.is_a?(String)
48
- value = amount
78
+ positive_format = format[:positive]
79
+ negative_format = format[:negative]
80
+ zero_format = format[:zero]
49
81
 
50
- if amount.negative? && format[:negative]
51
- format = format[:negative]
82
+ if amount.negative? && negative_format
83
+ format = negative_format
52
84
  value = -amount
53
- elsif amount.zero? && format[:zero]
54
- format = format[:zero]
85
+ elsif amount.zero? && zero_format
86
+ format = zero_format
87
+ value = amount
55
88
  else
56
- format = format[:positive]
89
+ format = positive_format
90
+ value = amount
57
91
  end
58
92
  format ||= '%<symbol>s%<amount>f'
59
93