minting 1.7.0 → 1.7.2

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +71 -118
  3. data/Rakefile +13 -1
  4. data/bin/bench_check +46 -0
  5. data/doc/Mint/Currency.html +178 -32
  6. data/doc/Mint/CurrencyRegistry.html +7 -7
  7. data/doc/Mint/Money.html +128 -125
  8. data/doc/Mint/RangeStepPatch.html +277 -0
  9. data/doc/Mint/UnknownCurrency.html +2 -2
  10. data/doc/Mint.html +47 -22
  11. data/doc/Minting.html +3 -3
  12. data/doc/_index.html +17 -2
  13. data/doc/class_list.html +1 -1
  14. data/doc/file.README.html +86 -89
  15. data/doc/index.html +86 -89
  16. data/doc/method_list.html +29 -21
  17. data/doc/top-level-namespace.html +13 -5
  18. data/lib/minting/{mint/currency → currency}/currency.rb +8 -0
  19. data/lib/minting/{mint/currency → currency}/world_currencies.rb +1 -1
  20. data/lib/minting/mint/aliases.rb +3 -0
  21. data/lib/minting/mint/dsl/{refinements.rb → numeric.rb} +6 -5
  22. data/lib/minting/mint/dsl/range.rb +31 -18
  23. data/lib/minting/mint/dsl/string.rb +12 -0
  24. data/lib/minting/mint/dsl/top_level.rb +3 -0
  25. data/lib/minting/mint/mint.rb +16 -2
  26. data/lib/minting/mint/parser/parser.rb +66 -0
  27. data/lib/minting/mint/parser/separators.rb +39 -0
  28. data/lib/minting/mint.rb +17 -8
  29. data/lib/minting/money/allocation/allocation.rb +25 -0
  30. data/lib/minting/money/{allocation.rb → allocation/split.rb} +1 -19
  31. data/lib/minting/money/arithmetics/methods.rb +27 -0
  32. data/lib/minting/money/{arithmetics.rb → arithmetics/operators.rb} +0 -21
  33. data/lib/minting/money/clamp.rb +66 -0
  34. data/lib/minting/money/coercion.rb +10 -0
  35. data/lib/minting/money/comparable.rb +6 -0
  36. data/lib/minting/money/constructors.rb +13 -3
  37. data/lib/minting/money/format/formatting.rb +44 -0
  38. data/lib/minting/money/{formatting.rb → format/to_s.rb} +0 -32
  39. data/lib/minting/money/money.rb +0 -58
  40. data/lib/minting/version.rb +1 -1
  41. metadata +19 -14
  42. data/lib/minting/mint/parser.rb +0 -85
  43. /data/lib/minting/{mint/currency → currency}/currency_registry.rb +0 -0
@@ -9,43 +9,56 @@ module Mint
9
9
  # iteration (+ / <=>) for non-numeric steps natively, so this patch is
10
10
  # only needed on older Rubies.
11
11
  module RangeStepPatch
12
- def step(step_size = nil, &)
12
+ # Iterates over the range using a Money step value.
13
+ # Overrides Range#step to handle Mint::Money step sizes on Ruby < 4.0.
14
+ #
15
+ # @param step_size [Mint::Money, nil] step amount
16
+ # @return [self, Enumerator] self if block given, Enumerator otherwise
17
+ def step(step_size = nil, &block)
13
18
  return super unless step_size.is_a?(Mint::Money)
14
19
 
15
20
  raise TypeError, "can't iterate from NilClass" unless self.begin
16
21
  raise ArgumentError, "step can't be 0" if step_size.zero?
17
22
 
18
- if block_given?
19
- each_money_step(step_size, &)
23
+ if block
24
+ each_money_step(step_size, &block)
20
25
  self
21
26
  else
22
- Enumerator.new do |yielder|
23
- each_money_step(step_size) { |v| yielder << v }
24
- end
27
+ Enumerator.new { |yielder| each_money_step(step_size) { |value| yielder << value } }
25
28
  end
26
29
  end
27
30
 
28
31
  private
29
32
 
30
- def each_money_step(step_amount)
33
+ # Dispatches to the appropriate iteration strategy based on range bounds.
34
+ # @private
35
+ def each_money_step(step, &)
36
+ self.end ? bounded_step(step, &) : unbounded_step(step, &)
37
+ end
38
+
39
+ # Iterates an open-ended range (no upper bound) with a Money step.
40
+ # @private
41
+ def unbounded_step(step)
31
42
  current = self.begin
32
- last = self.end
33
-
34
- unless last
35
- loop do
36
- yield current
37
- current += step_amount
38
- end
39
- return
43
+ loop do
44
+ yield current
45
+ current += step
40
46
  end
47
+ end
48
+
49
+ # Iterates a bounded range with a Money step, respecting direction and exclude_end?.
50
+ # @private
51
+ def bounded_step(step)
52
+ current = self.begin
53
+ last = self.end
54
+ asc = step.positive?
41
55
 
42
- ascending = step_amount.positive?
43
56
  loop do
44
- break if ascending ? current > last : current < last
57
+ break if asc ? current > last : current < last
45
58
  break if exclude_end? && current == last
46
59
 
47
60
  yield current
48
- current += step_amount
61
+ current += step
49
62
  end
50
63
  end
51
64
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mint String refinement
4
+ module Mint
5
+ refine String do
6
+ # Parses self as a numeric string and creates a Money in the given currency.
7
+ #
8
+ # @param currency [String, Symbol, Currency] target currency
9
+ # @return [Money]
10
+ def to_money(currency) = Mint.money(to_r, currency)
11
+ end
12
+ end
@@ -2,6 +2,9 @@
2
2
 
3
3
  # Mint refinements
4
4
  module Mint
5
+ # Registers top-level ::Money and ::Currency constants as aliases for Mint's classes.
6
+ #
7
+ # @raise [NameError] if ::Money or ::Currency are already defined and differ
5
8
  def self.use_top_level_constants!
6
9
  if !defined?(::Money) && !defined?(::Currency)
7
10
  require 'minting/mint/aliases'
@@ -24,11 +24,25 @@ module Mint
24
24
  when NilClass then nil
25
25
  when Currency then currency
26
26
  when String then CurrencyRegistry.currencies[currency]
27
- else raise ArgumentError, "currency must be [Currency] ot [String] (#{currency})"
27
+ else raise ArgumentError, "currency must be [Currency], [String] or nil (#{currency})"
28
28
  end
29
29
  end
30
30
 
31
- # Registers a new currency, raising a KeyError if already registered.
31
+ # Returns a zero {Money} in the given currency, useful as a default value
32
+ # for discounts, totals, or placeholders.
33
+ #
34
+ # @param currency [String, Currency] a currency code or object
35
+ # @return [Money] a frozen zero-Money
36
+ # @raise [ArgumentError] if the currency is not registered
37
+ def self.zero(currency)
38
+ checked = Mint.currency(currency)
39
+ raise ArgumentError, "Invalid Currency: [#{currency}]" unless checked
40
+
41
+ @zeros ||= CurrencyRegistry.currencies.values.to_h { |currency| [currency, Mint::Money.send(:new, 0, currency)] }
42
+ @zeros[currency] ||= Money.send(:new, 0, currency)
43
+ @zeros[currency]
44
+ end
45
+
32
46
  #
33
47
  # @param code [String] the unique currency code
34
48
  # @param subunit [Integer] the decimal subunit precision, defaults to 0
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mint Money parsing
4
+ module Mint
5
+ extend self
6
+
7
+ # Parses a human-readable money string into a {Money} object.
8
+ #
9
+ # @param input [String] Amount input, optionally including a currency symbol or code
10
+ # @param currency [String, Symbol, Currency, nil] ISO code when not present in +input+
11
+ # @return [Money]
12
+ # @raise [ArgumentError] when +input+ is invalid or currency cannot be determined
13
+ #
14
+ # @example With explicit currency
15
+ # Money.parse('19.99', 'USD') #=> [USD 19.99]
16
+ # Money.parse('1.234,56', 'EUR') #=> [EUR 1234.56]
17
+ #
18
+ # @example With symbol or code in the string
19
+ # Money.parse('$19.99') #=> [USD 19.99]
20
+ # Money.parse('19,99 €') #=> [EUR 19.99]
21
+ # Money.parse('USD 1,234.56') #=> [USD 1234.56]
22
+ def parse(input, currency = nil)
23
+ raise ArgumentError, 'input must be a String' unless input.is_a?(String)
24
+
25
+ input = input.strip
26
+ raise ArgumentError, 'input cannot be empty' if input.empty?
27
+
28
+ currency = Mint.currency(currency) || parse_currency(input)
29
+ raise ArgumentError, "Currency [#{currency}] not registered" unless currency
30
+
31
+ amount = currency.normalize_amount(parse_amount(input))
32
+ Mint::Money.new(amount, currency)
33
+ end
34
+
35
+ private
36
+
37
+ # Extracts a numeric value from input that should only contain an amount.
38
+ # @private
39
+ def parse_amount(input)
40
+ accounting_negative = input.start_with?('(') && input.end_with?(')')
41
+
42
+ # Remove any charater that is not a digit, comma or period
43
+ numeric = input.scan(/[\d.,-]/).join
44
+ amount = Rational(normalize_separators(numeric))
45
+ accounting_negative ? -amount : amount
46
+ end
47
+
48
+ # Extracts currency from a string by matching ISO code or symbol.
49
+ #
50
+ # Scans all uppercase words and returns the first registered code, falling
51
+ # back to symbol matching. This correctly handles inputs like
52
+ # "MAX 10.00 USD" where the first uppercase word isn't a currency code.
53
+ # @private
54
+ def parse_currency(input)
55
+ input.scan(/\b([A-Z_]+)\b/) do |(code)|
56
+ currency = Mint.currency(code)
57
+ return currency if currency
58
+ end
59
+
60
+ CurrencyRegistry.currency_symbols.each do |symbol, currency|
61
+ return currency if input.include?(symbol)
62
+ end
63
+
64
+ raise ArgumentError, 'Currency could not be detected'
65
+ end
66
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mint Money parsing
4
+ module Mint
5
+ extend self
6
+
7
+ private
8
+
9
+ # Classifies the separator pattern in a numeric string.
10
+ # @private
11
+ def classify_separators(numeric)
12
+ case [numeric.count('.'), numeric.count(',')]
13
+ in [0, 1] if numeric[-4] == ',' then :thousands_comma # Comma is a thousand separator
14
+ in [0, 1] then :decimal_comma # Only one comma: decimal (e.g. 19,99 or 1,4 or 1,2345).
15
+ in [0, 0] | [1, 0] then :decimal_period # e.g. "1500" or "34.21".
16
+ in [p, c] if p > 1 && c > 1 then :ambiguous # Both separators appear multiple times
17
+ in [p, c] if p > 0 && c > 0 then :mixed # Commas and dots: the rightmost one is the decimal
18
+ else :thousands # Multiple of the same separator only (e.g. 1,234,567)
19
+ end
20
+ end
21
+
22
+ # Converts locale-specific decimal/thousand separators into a plain decimal string.
23
+ # @private
24
+ def normalize_separators(numeric)
25
+ case classify_separators(numeric)
26
+ when :decimal_period then numeric # Nothing to normalize (e.g. "1500" or "34.21").
27
+ when :decimal_comma then numeric.tr(',', '.') # Only one comma: decimal (e.g. 19,99 or 1,234).
28
+ when :thousands_comma then numeric.delete(',')
29
+ when :thousands then numeric.delete('.,')
30
+ when :ambiguous then raise ArgumentError, "could not distinguish decimal and thousand separators in '#{numeric}'"
31
+ when :mixed # Commas and dots: the rightmost one is the decimal separator.
32
+ if numeric.rindex(',') > numeric.rindex('.')
33
+ numeric.delete('.').tr(',', '.')
34
+ else
35
+ numeric.delete(',')
36
+ end
37
+ end
38
+ end
39
+ end
data/lib/minting/mint.rb CHANGED
@@ -1,18 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'minting/mint/currency/currency'
4
- require 'minting/mint/currency/currency_registry'
5
- require 'minting/mint/currency/world_currencies'
3
+ require 'minting/currency/currency'
4
+ require 'minting/currency/currency_registry'
5
+ require 'minting/currency/world_currencies'
6
+
7
+ require 'minting/mint/dsl/numeric'
6
8
  require 'minting/mint/dsl/range'
7
- require 'minting/mint/dsl/refinements'
9
+ require 'minting/mint/dsl/string'
8
10
  require 'minting/mint/dsl/top_level'
11
+
9
12
  require 'minting/mint/mint'
10
- require 'minting/mint/parser'
11
- require 'minting/money/allocation'
12
- require 'minting/money/arithmetics'
13
+ require 'minting/mint/parser/parser'
14
+ require 'minting/mint/parser/separators'
15
+
16
+ require 'minting/money/allocation/allocation'
17
+ require 'minting/money/allocation/split'
18
+ require 'minting/money/arithmetics/methods'
19
+ require 'minting/money/arithmetics/operators'
20
+ require 'minting/money/clamp'
13
21
  require 'minting/money/coercion'
14
22
  require 'minting/money/comparable'
15
23
  require 'minting/money/constructors'
16
24
  require 'minting/money/conversion'
17
- require 'minting/money/formatting'
25
+ require 'minting/money/format/formatting'
26
+ require 'minting/money/format/to_s'
18
27
  require 'minting/money/money'
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mint
4
+ # Allocation and splitting
5
+ class Money
6
+ # Proportionally allocates the monetary amount among a list of ratios.
7
+ # Disperses any subunit rounding amounts across the initial slots
8
+ # @param proportions [Array<Numeric>] a list of numeric proportions/ratios to allocate by
9
+ # @return [Array<Money>] the list of newly allocated Money objects
10
+ # @raise [ArgumentError] if the proportions list is empty or sums to zero
11
+ #
12
+ # @example Proportional allocation
13
+ # money = Mint.money(10.00, 'USD')
14
+ # money.allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
15
+ def allocate(proportions)
16
+ whole = proportions.sum.to_r
17
+ raise ArgumentError, 'Need at least 1 proportion element' if proportions.empty?
18
+ raise ArgumentError, 'Proportions total must not be zero' if whole.zero?
19
+
20
+ subunit = currency.subunit
21
+ amounts = proportions.map { |rate| Rational(amount * rate, whole).round(subunit) }
22
+ allocate_left_over(amounts: amounts, left_over: amount - amounts.sum)
23
+ end
24
+ end
25
+ end
@@ -3,25 +3,6 @@
3
3
  module Mint
4
4
  # Allocation and splitting
5
5
  class Money
6
- # Proportionally allocates the monetary amount among a list of ratios.
7
- # Disperses any subunit rounding amounts across the initial slots
8
- # @param proportions [Array<Numeric>] a list of numeric proportions/ratios to allocate by
9
- # @return [Array<Money>] the list of newly allocated Money objects
10
- # @raise [ArgumentError] if the proportions list is empty or sums to zero
11
- #
12
- # @example Proportional allocation
13
- # money = Mint.money(10.00, 'USD')
14
- # money.allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
15
- def allocate(proportions)
16
- whole = proportions.sum.to_r
17
- raise ArgumentError, 'Need at least 1 proportion element' if proportions.empty?
18
- raise ArgumentError, 'Proportions total must not be zero' if whole.zero?
19
-
20
- subunit = currency.subunit
21
- amounts = proportions.map { |rate| Rational(amount * rate, whole).round(subunit) }
22
- allocate_left_over(amounts: amounts, left_over: amount - amounts.sum)
23
- end
24
-
25
6
  # Splits the monetary amount into a given quantity of equal parts.
26
7
  # Disperses any fractional subunit rounding differences across the initial slots
27
8
  # so that the sum is preserved.
@@ -46,6 +27,7 @@ module Mint
46
27
  # Distributes any leftover amount across the allocation slots by adjusting
47
28
  # individual amounts by the currency's minimum unit, and converting to Money.
48
29
  # Caution: amounts array is mutated by this method
30
+ # @private
49
31
  def allocate_left_over(amounts:, left_over:)
50
32
  if left_over.nonzero?
51
33
  minimum = currency.minimum_amount
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mint
4
+ # Money Arithmetics
5
+ class Money
6
+ # Returns the absolute value of the monetary amount as a new {Money} instance.
7
+ #
8
+ # @return [Money] the absolute value
9
+ def abs = mint(amount.abs)
10
+
11
+ # Returns true if the monetary amount is less than zero.
12
+ #
13
+ # @return [Boolean] true if negative, false otherwise
14
+ def negative? = amount.negative?
15
+
16
+ # Returns true if the monetary amount is greater than zero.
17
+ #
18
+ # @return [Boolean] true if positive, false otherwise
19
+ def positive? = amount.positive?
20
+
21
+ # Returns the successor of the Money instance by adding the minimum possible subunit amount.
22
+ # Enables standard ranges and stepping (e.g. `1.dollar..10.dollars`).
23
+ #
24
+ # @return [Money] successor Money instance
25
+ def succ = mint(amount + currency.minimum_amount)
26
+ end
27
+ end
@@ -3,27 +3,6 @@
3
3
  module Mint
4
4
  # Money Arithmetics
5
5
  class Money
6
- # Returns the absolute value of the monetary amount as a new {Money} instance.
7
- #
8
- # @return [Money] the absolute value
9
- def abs = mint(amount.abs)
10
-
11
- # Returns true if the monetary amount is less than zero.
12
- #
13
- # @return [Boolean] true if negative, false otherwise
14
- def negative? = amount.negative?
15
-
16
- # Returns true if the monetary amount is greater than zero.
17
- #
18
- # @return [Boolean] true if positive, false otherwise
19
- def positive? = amount.positive?
20
-
21
- # Returns the successor of the Money instance by adding the minimum possible subunit amount.
22
- # Enables standard ranges and stepping (e.g. `1.dollar..10.dollars`).
23
- #
24
- # @return [Money] successor Money instance
25
- def succ = mint(amount + currency.minimum_amount)
26
-
27
6
  # Performs addition with another {Money} instance or standard zero Numeric.
28
7
  #
29
8
  # @param addend [Money, Numeric] the value to add
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mint
4
+ # Money clamp
5
+ class Money
6
+ # Constrains +self+ to the inclusive range [+min+, +max+].
7
+ #
8
+ # Bounds may be:
9
+ # - nil meaning no boundary
10
+ # - same-currency {Money} or Range
11
+ # - Numeric amount, or Range
12
+ #
13
+ # Numeric is interpreted as an amount in +self+'s currency, so the common
14
+ # pricing idiom +price.clamp(0, 100)+ reads as "0 to 100 in the same
15
+ # currency as +price+".
16
+ #
17
+ # When +self+ is already in range the receiver is returned (no new object
18
+ # allocated). When out of range, the nearest bound is returned as a new
19
+ # frozen {Money} in +self+'s currency.
20
+ #
21
+ # @param min_or_range [Money, Numeric, Range, nil] lower bound (inclusive), or range
22
+ # @param max [Money, Numeric, nil] upper bound (inclusive)
23
+ # @return [Money] +self+ if in range, otherwise the nearer bound
24
+ # @raise [ArgumentError] if +min+ or +max+ is not a Money, Numeric or nil; if
25
+ # a Money operand has a different currency; if +min+ > +max+;
26
+ # if min is a Range, and max is not nil
27
+ #
28
+ # @example In range
29
+ # Mint.money(5, 'USD').clamp(0, 10) #=> [USD 5.00] (returns self)
30
+ #
31
+ # @example Out of range, with Numeric bounds
32
+ # Mint.money(50, 'USD').clamp(0, 10) #=> [USD 10.00]
33
+ #
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')
38
+ # loss.clamp(floor, ceil) #=> [USD 0.00]
39
+ #
40
+ # @example Subunit-0 currency (JPY)
41
+ # Mint.money(500, 'JPY').clamp(0, 100) #=> [JPY 100]
42
+ def clamp(min_or_range, max = nil)
43
+ if min_or_range.is_a?(Range)
44
+ raise(ArgumentError, "Either amount range alone or two amounts accepted: #{max}") if max
45
+
46
+ min, max = min_or_range.minmax
47
+ else
48
+ min = min_or_range
49
+ end
50
+ mint(amount.clamp(normalize_boundary(min), normalize_boundary(max)))
51
+ end
52
+
53
+ private
54
+
55
+ # Converts a clamp boundary to a numeric amount.
56
+ # @private
57
+ def normalize_boundary(boundary)
58
+ case boundary
59
+ in NilClass | Numeric then boundary
60
+ in Money if same_currency?(boundary) then boundary.amount
61
+ in Money then raise ArgumentError, "oundary currency must be: #{currency_code}"
62
+ else raise ArgumentError, "Boundary must be Numeric or Money #{boundary}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -23,6 +23,8 @@ module Mint
23
23
  def initialize(value) = @value = value
24
24
 
25
25
  # @private
26
+ # Adds a CoercedNumber to a Money object.
27
+ # Only zero is a valid additive identity (returns the Money unchanged).
26
28
  def +(other)
27
29
  raise_coercion_error(:+, other) unless @value.zero?
28
30
 
@@ -30,6 +32,8 @@ module Mint
30
32
  end
31
33
 
32
34
  # @private
35
+ # Subtracts a Money object from a CoercedNumber.
36
+ # Only zero is valid (returns the negated Money).
33
37
  def -(other)
34
38
  raise_coercion_error(:-, other) unless @value.zero?
35
39
 
@@ -37,15 +41,20 @@ module Mint
37
41
  end
38
42
 
39
43
  # @private
44
+ # Multiplies a Money object by the wrapped numeric value.
45
+ # This is the standard coercion path for `Numeric * Money`.
40
46
  def *(other)
41
47
  other.mint(@value * other.amount)
42
48
  end
43
49
 
44
50
  # @private
51
+ # Divides a CoercedNumber by a Money object.
52
+ # Not a meaningful operation (what currency is the result?).
45
53
  def /(other)
46
54
  raise_coercion_error(:/, other)
47
55
  end
48
56
 
57
+ # @private
49
58
  # Only zero is dimensionless and comparable to Money.
50
59
  # e.g. 0 < price is meaningful; 0.5 < price is not (what currency is 0.5?).
51
60
  def <=>(other)
@@ -56,6 +65,7 @@ module Mint
56
65
 
57
66
  private
58
67
 
68
+ # Raises a TypeError with a descriptive message for unsupported coercions.
59
69
  def raise_coercion_error(operation, operand)
60
70
  raise TypeError, "#{@value} #{operation} #{operand} : incompatible operands"
61
71
  end
@@ -14,6 +14,10 @@ module Mint
14
14
  end
15
15
  end
16
16
 
17
+ # Strict equality — both amount and currency must match exactly.
18
+ # Unlike ==, does not treat zero as equivalent across currencies.
19
+ #
20
+ # @return [Boolean]
17
21
  def eql?(other)
18
22
  other.is_a?(Mint::Money) &&
19
23
  amount == other.amount &&
@@ -37,8 +41,10 @@ module Mint
37
41
  end
38
42
  end
39
43
 
44
+ # @return [self, nil] self if amount is non-zero, nil otherwise
40
45
  def nonzero? = amount.nonzero?
41
46
 
47
+ # @return [Boolean] true if amount is zero
42
48
  def zero? = amount.zero?
43
49
  end
44
50
  end
@@ -13,7 +13,9 @@ module Mint
13
13
  checked_currency = Mint.currency(currency)
14
14
  raise ArgumentError, "Currency not found (#{currency})" unless checked_currency
15
15
 
16
- new(checked_currency.normalize_amount(amount), checked_currency)
16
+ amount = checked_currency.normalize_amount(amount)
17
+
18
+ amount.zero? ? Mint.zero(checked_currency) : new(amount, checked_currency)
17
19
  end
18
20
 
19
21
  # Builds a Money from a fractional (smallest-unit) Integer amount.
@@ -41,7 +43,8 @@ module Mint
41
43
  raise ArgumentError, "Currency not found (#{currency})" unless checked_currency
42
44
 
43
45
  amount = Rational(fractional, checked_currency.fractional_multiplier)
44
- new(amount, checked_currency)
46
+
47
+ amount.zero? ? Mint.zero(checked_currency) : new(amount, checked_currency)
45
48
  end
46
49
 
47
50
  # Returns a new Money object with the specified amount, or self if unchanged.
@@ -56,7 +59,14 @@ module Mint
56
59
  # price.mint(10.00) #=> [USD 10.00] (returns self)
57
60
  def mint(new_amount)
58
61
  new_amount = currency.normalize_amount(new_amount)
59
- new_amount == amount ? self : Money.new(new_amount, currency)
62
+
63
+ if new_amount == amount
64
+ self
65
+ elsif new_amount.zero?
66
+ Mint.zero(currency)
67
+ else
68
+ Money.new(new_amount, currency)
69
+ end
60
70
  end
61
71
 
62
72
  private
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mint
4
+ # Formatting functionality for Money objects
5
+ class Money
6
+ private
7
+
8
+ # Selects the appropriate format template and value based on the amount's sign.
9
+ # @private
10
+ def select_format(format)
11
+ negative_format = format[:negative]
12
+ zero_format = format[:zero]
13
+
14
+ if amount.negative? && negative_format
15
+ [negative_format, -amount]
16
+ elsif amount.zero? && zero_format
17
+ [zero_format, amount]
18
+ else
19
+ [format[:positive], amount]
20
+ end
21
+ end
22
+
23
+ # Validates that format hash contains only known keys.
24
+ # @private
25
+ def validate_format_hash(format)
26
+ unknown = format.keys - %i[positive negative zero]
27
+
28
+ raise ArgumentError, "Unknown format parameter(s): #{unknown.inspect}. " unless unknown.empty?
29
+ end
30
+
31
+ # Applies a format template to produce a formatted string representation.
32
+ # @private
33
+ def format_amount(format)
34
+ format, value = select_format(format)
35
+ format ||= '%<symbol>s%<amount>f'
36
+ # Automatically adjust decimal places based on currency subunit if missing
37
+ format = format.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{currency.subunit}f")
38
+
39
+ refs = format.scan(/%<(\w+)>/).flatten.map(&:to_sym)
40
+ all_args = { amount: value, currency: currency_code, symbol: currency.symbol }
41
+ Kernel.format(format, **all_args.slice(*refs))
42
+ end
43
+ end
44
+ end
@@ -63,37 +63,5 @@ module Mint
63
63
 
64
64
  width ? formatted.rjust(width) : formatted
65
65
  end
66
-
67
- private
68
-
69
- def select_format(format)
70
- negative_format = format[:negative]
71
- zero_format = format[:zero]
72
-
73
- if amount.negative? && negative_format
74
- [negative_format, -amount]
75
- elsif amount.zero? && zero_format
76
- [zero_format, amount]
77
- else
78
- [format[:positive], amount]
79
- end
80
- end
81
-
82
- def validate_format_hash(format)
83
- unknown = format.keys - %i[positive negative zero]
84
-
85
- raise ArgumentError, "Unknown format parameter(s): #{unknown.inspect}. " unless unknown.empty?
86
- end
87
-
88
- def format_amount(format)
89
- format, value = select_format(format)
90
- format ||= '%<symbol>s%<amount>f'
91
- # Automatically adjust decimal places based on currency subunit if missing
92
- format = format.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{currency.subunit}f")
93
-
94
- refs = format.scan(/%<(\w+)>/).flatten.map(&:to_sym)
95
- all_args = { amount: value, currency: currency_code, symbol: currency.symbol }
96
- Kernel.format(format, **all_args.slice(*refs))
97
- end
98
66
  end
99
67
  end