minting 1.6.3 → 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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +71 -113
  3. data/Rakefile +25 -3
  4. data/bin/bench_check +46 -0
  5. data/doc/Mint/Currency.html +1139 -0
  6. data/doc/Mint/CurrencyRegistry.html +511 -0
  7. data/doc/Mint/Money.html +3859 -0
  8. data/doc/Mint/RangeStepPatch.html +277 -0
  9. data/doc/Mint/UnknownCurrency.html +136 -0
  10. data/doc/Mint.html +911 -0
  11. data/doc/Minting.html +142 -0
  12. data/doc/_index.html +173 -0
  13. data/doc/class_list.html +54 -0
  14. data/doc/css/common.css +1 -0
  15. data/doc/css/full_list.css +206 -0
  16. data/doc/css/style.css +1089 -0
  17. data/doc/file.README.html +275 -0
  18. data/doc/file_list.html +59 -0
  19. data/doc/frames.html +22 -0
  20. data/doc/index.html +275 -0
  21. data/doc/js/app.js +801 -0
  22. data/doc/js/full_list.js +334 -0
  23. data/doc/js/jquery.js +4 -0
  24. data/doc/method_list.html +518 -0
  25. data/doc/top-level-namespace.html +151 -0
  26. data/lib/minting/{mint/currency → currency}/currency.rb +8 -0
  27. data/lib/minting/{mint/currency → currency}/currency_registry.rb +1 -1
  28. data/lib/minting/{mint/currency → currency}/world_currencies.rb +1 -1
  29. data/lib/minting/mint/aliases.rb +3 -0
  30. data/lib/minting/mint/dsl/numeric.rb +23 -0
  31. data/lib/minting/mint/dsl/range.rb +67 -0
  32. data/lib/minting/mint/dsl/string.rb +12 -0
  33. data/lib/minting/mint/{dsl.rb → dsl/top_level.rb} +3 -18
  34. data/lib/minting/mint/mint.rb +17 -3
  35. data/lib/minting/mint/{parser.rb → parser/parser.rb} +17 -29
  36. data/lib/minting/mint/parser/separators.rb +39 -0
  37. data/lib/minting/mint.rb +19 -8
  38. data/lib/minting/money/allocation/allocation.rb +25 -0
  39. data/lib/minting/money/allocation/split.rb +41 -0
  40. data/lib/minting/money/arithmetics/methods.rb +27 -0
  41. data/lib/minting/money/{arithmetics.rb → arithmetics/operators.rb} +5 -26
  42. data/lib/minting/money/clamp.rb +66 -0
  43. data/lib/minting/money/coercion.rb +18 -11
  44. data/lib/minting/money/comparable.rb +6 -0
  45. data/lib/minting/money/constructors.rb +13 -3
  46. data/lib/minting/money/format/formatting.rb +44 -0
  47. data/lib/minting/money/{formatting.rb → format/to_s.rb} +5 -42
  48. data/lib/minting/money/money.rb +0 -58
  49. data/lib/minting/version.rb +1 -1
  50. data/minting.gemspec +5 -2
  51. metadata +42 -11
  52. data/lib/minting/money/allocation.rb +0 -59
@@ -24,7 +24,7 @@ module Mint
24
24
  def currency_symbols
25
25
  @currency_symbols ||= begin
26
26
  currencies.values
27
- .reject { |currency| currency.symbol.empty? }
27
+ .reject { |c| c.symbol.empty? }
28
28
  .map { |currency| [currency.symbol, currency] }
29
29
  .sort_by { |symbol, currency| [-symbol.length, -currency.priority] }
30
30
  end.freeze
@@ -8,7 +8,7 @@ module Mint
8
8
  # @api private
9
9
  def self.world_currencies
10
10
  @world_currencies ||= begin
11
- path = File.join(File.expand_path('../../data', __dir__), 'world-currencies.yaml')
11
+ path = File.join(File.expand_path('../data', __dir__), 'world-currencies.yaml')
12
12
 
13
13
  YAML.load_file(path).to_h { |entry| [entry['code'], Currency.new(**entry.transform_keys(&:to_sym))] }
14
14
  end.freeze
@@ -3,5 +3,8 @@
3
3
  # Optional top‑level aliases for application use.
4
4
  # Not required automatically.
5
5
 
6
+ # Alias for {Mint::Money} — enables `Money.new(...)` shorthand.
6
7
  Money = Mint::Money
8
+
9
+ # Alias for {Mint::Currency} — enables `Currency.new(...)` shorthand.
7
10
  Currency = Mint::Currency
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mint Numeric refinements
4
+ module Mint
5
+ refine Numeric do
6
+ # @return [Money] self interpreted as BRL
7
+ def reais = Mint.money(self, 'BRL')
8
+
9
+ # @return [Money] self interpreted as USD
10
+ def dollars = Mint.money(self, 'USD')
11
+
12
+ # @return [Money] self interpreted as EUR
13
+ def euros = Mint.money(self, 'EUR')
14
+
15
+ # @param currency [String, Symbol, Currency] target currency
16
+ # @return [Money] self interpreted as the given currency
17
+ def to_money(currency) = Mint.money(self, currency)
18
+
19
+ alias_method :dollar, :dollars
20
+ alias_method :euro, :euros
21
+ alias_method :mint, :to_money
22
+ end
23
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mint Range patch
4
+ # @api private
5
+ module Mint
6
+ if RUBY_VERSION < '4.0'
7
+ # Ruby < 4.0's Range#step calls rb_to_int on non-Numeric step arguments,
8
+ # which raises TypeError for Money objects. Ruby 4.0+ uses arithmetic
9
+ # iteration (+ / <=>) for non-numeric steps natively, so this patch is
10
+ # only needed on older Rubies.
11
+ module RangeStepPatch
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)
18
+ return super unless step_size.is_a?(Mint::Money)
19
+
20
+ raise TypeError, "can't iterate from NilClass" unless self.begin
21
+ raise ArgumentError, "step can't be 0" if step_size.zero?
22
+
23
+ if block
24
+ each_money_step(step_size, &block)
25
+ self
26
+ else
27
+ Enumerator.new { |yielder| each_money_step(step_size) { |value| yielder << value } }
28
+ end
29
+ end
30
+
31
+ private
32
+
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)
42
+ current = self.begin
43
+ loop do
44
+ yield current
45
+ current += step
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?
55
+
56
+ loop do
57
+ break if asc ? current > last : current < last
58
+ break if exclude_end? && current == last
59
+
60
+ yield current
61
+ current += step
62
+ end
63
+ end
64
+ end
65
+ Range.prepend(Mint::RangeStepPatch)
66
+ end
67
+ 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,24 +2,9 @@
2
2
 
3
3
  # Mint refinements
4
4
  module Mint
5
- refine Numeric do
6
- def reais = Mint.money(self, 'BRL')
7
-
8
- def dollars = Mint.money(self, 'USD')
9
-
10
- def euros = Mint.money(self, 'EUR')
11
-
12
- def to_money(currency) = Mint.money(self, currency)
13
-
14
- alias_method :dollar, :dollars
15
- alias_method :euro, :euros
16
- alias_method :mint, :to_money
17
- end
18
-
19
- refine String do
20
- def to_money(currency) = Mint.money(to_r, currency)
21
- end
22
-
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
23
8
  def self.use_top_level_constants!
24
9
  if !defined?(::Money) && !defined?(::Currency)
25
10
  require 'minting/mint/aliases'
@@ -21,14 +21,28 @@ module Mint
21
21
  # @return [Currency, nil] the registered Currency instance or nil if not found
22
22
  def self.currency(currency)
23
23
  case currency
24
- when nil then nil
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
@@ -35,44 +35,32 @@ module Mint
35
35
  private
36
36
 
37
37
  # Extracts a numeric value from input that should only contain an amount.
38
+ # @private
38
39
  def parse_amount(input)
40
+ accounting_negative = input.start_with?('(') && input.end_with?(')')
41
+
39
42
  # Remove any charater that is not a digit, comma or period
40
43
  numeric = input.scan(/[\d.,-]/).join
41
- numeric = normalize_separators(numeric)
42
- Rational(numeric)
43
- end
44
-
45
- # Converts locale-specific decimal/thousand separators into a plain decimal string.
46
- def normalize_separators(numeric)
47
- case [numeric.count(','), numeric.count('.')]
48
- in [0, 0] | [0, 1] then numeric # Nothing to normalize (e.g. "1500" or "34.21").
49
- in [1, 0] then numeric.tr(',', '.') # Only one comma: decimal (e.g. 19,99 or 1,234).
50
- in [c, p] if c > 1 && p > 1 # Both separators appear multiple times
51
- raise ArgumentError, "could not distinguish decimal and thousand separators in '#{numeric}'"
52
- in [c, p] if c > 0 && p > 0 # Commas and dots: the rightmost one is the decimal separator.
53
- if numeric.rindex(',') > numeric.rindex('.')
54
- numeric.delete('.').tr(',', '.')
55
- else
56
- numeric.delete(',')
57
- end
58
- else # Multiple of the same separator only (e.g. 1,234,567) — all are thousands.
59
- numeric.delete(',.')
60
- end
44
+ amount = Rational(normalize_separators(numeric))
45
+ accounting_negative ? -amount : amount
61
46
  end
62
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
63
54
  def parse_currency(input)
64
- case input
65
- when nil then return nil
66
- when String
67
- # Prefer an explicit ISO 4217 code (e.g. "USD 1,234.56") over symbol matching.
68
- currency = Mint.currency(input[/\b([A-Z_]+)\b/, 1])
55
+ input.scan(/\b([A-Z_]+)\b/) do |(code)|
56
+ currency = Mint.currency(code)
69
57
  return currency if currency
58
+ end
70
59
 
71
- # Fall back to registered symbols, longest first (HK$ before $).
72
- CurrencyRegistry.currency_symbols.each do |symbol, currency|
73
- return currency if input.include?(symbol)
74
- end
60
+ CurrencyRegistry.currency_symbols.each do |symbol, currency|
61
+ return currency if input.include?(symbol)
75
62
  end
63
+
76
64
  raise ArgumentError, 'Currency could not be detected'
77
65
  end
78
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,16 +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'
6
- require 'minting/mint/dsl'
3
+ require 'minting/currency/currency'
4
+ require 'minting/currency/currency_registry'
5
+ require 'minting/currency/world_currencies'
6
+
7
+ require 'minting/mint/dsl/numeric'
8
+ require 'minting/mint/dsl/range'
9
+ require 'minting/mint/dsl/string'
10
+ require 'minting/mint/dsl/top_level'
11
+
7
12
  require 'minting/mint/mint'
8
- require 'minting/mint/parser'
9
- require 'minting/money/allocation'
10
- 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'
11
21
  require 'minting/money/coercion'
12
22
  require 'minting/money/comparable'
13
23
  require 'minting/money/constructors'
14
24
  require 'minting/money/conversion'
15
- require 'minting/money/formatting'
25
+ require 'minting/money/format/formatting'
26
+ require 'minting/money/format/to_s'
16
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
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mint
4
+ # Allocation and splitting
5
+ class Money
6
+ # Splits the monetary amount into a given quantity of equal parts.
7
+ # Disperses any fractional subunit rounding differences across the initial slots
8
+ # so that the sum is preserved.
9
+ #
10
+ # @param slices [Integer] the number of equal parts to divide the money into (must be > 0)
11
+ # @return [Array<Money>] the list of newly split Money objects
12
+ # @raise [ArgumentError] if quantity is not a positive integer
13
+ #
14
+ # @example Even split
15
+ # money = Mint.money(10.00, 'USD')
16
+ # money.split(3) #=> [[USD 3.34], [USD 3.33], [USD 3.33]]
17
+ def split(slices)
18
+ raise ArgumentError, 'Slices quantity must be an poitive integer' unless slices.positive? && slices.integer?
19
+
20
+ fraction = (amount / slices).round(currency.subunit)
21
+ allocate_left_over(amounts: Array.new(slices, fraction),
22
+ left_over: amount - (fraction * slices))
23
+ end
24
+
25
+ private
26
+
27
+ # Distributes any leftover amount across the allocation slots by adjusting
28
+ # individual amounts by the currency's minimum unit, and converting to Money.
29
+ # Caution: amounts array is mutated by this method
30
+ # @private
31
+ def allocate_left_over(amounts:, left_over:)
32
+ if left_over.nonzero?
33
+ minimum = currency.minimum_amount
34
+ minimum = -minimum if left_over.negative?
35
+ slots_to_adjust = (left_over / minimum).to_i
36
+ (0...slots_to_adjust).each { |slot| amounts[slot] += minimum }
37
+ end
38
+ amounts.map! { |amount| Money.new(amount, currency) }
39
+ end
40
+ end
41
+ end
@@ -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
@@ -31,10 +10,10 @@ module Mint
31
10
  # @raise [TypeError] if addition involves a different currency or incompatible types
32
11
  def +(addend)
33
12
  case addend
34
- when 0 then return self
35
- when Money then return mint(amount + addend.amount) if same_currency?(addend)
13
+ in 0 then self
14
+ in Money if same_currency?(addend) then mint(amount + addend.amount)
15
+ else raise TypeError, "#{addend} can't be added to #{self}"
36
16
  end
37
- raise TypeError, "#{addend} can't be added to #{self}"
38
17
  end
39
18
 
40
19
  # Performs subtraction with another {Money} instance or standard zero Numeric.
@@ -61,9 +40,9 @@ module Mint
61
40
  # @return [Money] the multiplied Money instance
62
41
  # @raise [TypeError] if multiplier is not Numeric or is a Money object
63
42
  def *(multiplicand)
64
- return mint(amount * multiplicand) if multiplicand.is_a?(Numeric)
43
+ raise TypeError, "#{self} can't be multiplied by #{multiplicand}" unless multiplicand.is_a?(Numeric)
65
44
 
66
- raise TypeError, "#{self} can't be multiplied by #{multiplicand}"
45
+ mint(amount * multiplicand)
67
46
  end
68
47
 
69
48
  # Performs division of the monetary value by a scalar Numeric or identical currency {Money}.
@@ -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
@@ -4,13 +4,13 @@ module Mint
4
4
  # Implements the standard Ruby coercion protocol.
5
5
  class Money
6
6
  # Allows {Money} to interact seamlessly as the right-hand operand in Numeric arithmetic.
7
- # This enables expressions like `5 + money` where `5` is a Numeric and `money` is a Money object.
7
+ # This enables expressions like `5 * money` where `5` is a Numeric and `money` is a Money object.
8
8
  #
9
9
  # @param other [Numeric] the left-hand operand to coerce
10
10
  # @return [Array(CoercedNumber, Money)] coerced operand array
11
11
  # @example
12
12
  # price = Mint.money(10, 'USD')
13
- # 5 + price #=> [USD 15.00] (via coercion)
13
+ # 5 * price #=> [USD 50.00] (via coercion)
14
14
  def coerce(other)
15
15
  [CoercedNumber.new(other), self]
16
16
  end
@@ -20,34 +20,41 @@ module Mint
20
20
  # @private
21
21
  class CoercedNumber
22
22
  # @private
23
- def initialize(value)
24
- @value = value
25
- end
23
+ def initialize(value) = @value = value
26
24
 
27
25
  # @private
26
+ # Adds a CoercedNumber to a Money object.
27
+ # Only zero is a valid additive identity (returns the Money unchanged).
28
28
  def +(other)
29
- return other if @value.zero?
29
+ raise_coercion_error(:+, other) unless @value.zero?
30
30
 
31
- raise_coercion_error(:+, other)
31
+ other
32
32
  end
33
33
 
34
34
  # @private
35
+ # Subtracts a Money object from a CoercedNumber.
36
+ # Only zero is valid (returns the negated Money).
35
37
  def -(other)
36
- return -other if @value.zero?
38
+ raise_coercion_error(:-, other) unless @value.zero?
37
39
 
38
- raise_coercion_error(:-, other)
40
+ -other
39
41
  end
40
42
 
41
43
  # @private
44
+ # Multiplies a Money object by the wrapped numeric value.
45
+ # This is the standard coercion path for `Numeric * Money`.
42
46
  def *(other)
43
47
  other.mint(@value * other.amount)
44
48
  end
45
49
 
46
50
  # @private
51
+ # Divides a CoercedNumber by a Money object.
52
+ # Not a meaningful operation (what currency is the result?).
47
53
  def /(other)
48
54
  raise_coercion_error(:/, other)
49
55
  end
50
56
 
57
+ # @private
51
58
  # Only zero is dimensionless and comparable to Money.
52
59
  # e.g. 0 < price is meaningful; 0.5 < price is not (what currency is 0.5?).
53
60
  def <=>(other)
@@ -58,9 +65,9 @@ module Mint
58
65
 
59
66
  private
60
67
 
68
+ # Raises a TypeError with a descriptive message for unsupported coercions.
61
69
  def raise_coercion_error(operation, operand)
62
- raise TypeError,
63
- "#{@value} #{operation} #{operand} : incompatible operands"
70
+ raise TypeError, "#{@value} #{operation} #{operand} : incompatible operands"
64
71
  end
65
72
  end
66
73
  private_constant :CoercedNumber
@@ -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