minting 1.2.0 → 1.4.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.
@@ -60,7 +60,7 @@ module Mint
60
60
  # @return [Money] the multiplied Money instance
61
61
  # @raise [TypeError] if multiplier is not Numeric or is a Money object
62
62
  def *(multiplicand)
63
- return mint(amount * multiplicand.to_r) if multiplicand.is_a?(Numeric)
63
+ return mint(amount * multiplicand) if multiplicand.is_a?(Numeric)
64
64
 
65
65
  raise TypeError, "#{self} can't be multiplied by #{multiplicand}"
66
66
  end
@@ -13,8 +13,6 @@ module Mint
13
13
  # Coerced Number contains the arithmetic logic for numeric compatible ops.
14
14
  # @private
15
15
  class CoercedNumber
16
- include Comparable
17
-
18
16
  # @private
19
17
  def initialize(value)
20
18
  @value = value
@@ -44,19 +42,21 @@ module Mint
44
42
  raise_coercion_error(:/, other)
45
43
  end
46
44
 
47
- # @private
45
+ # Only zero is dimensionless and comparable to Money.
46
+ # e.g. 0 < price is meaningful; 0.5 < price is not (what currency is 0.5?).
48
47
  def <=>(other)
49
- return nil if @value.nil? || other.nil?
50
48
  return @value <=> other.amount if @value.zero? || other.zero?
51
49
 
52
50
  raise_coercion_error(:<=>, other)
53
51
  end
54
52
 
55
- # @private
53
+ private
54
+
56
55
  def raise_coercion_error(operation, operand)
57
56
  raise TypeError,
58
- "#{self} #{operation} #{operand} : incompatible operands"
57
+ "#{@value} #{operation} #{operand} : incompatible operands"
59
58
  end
60
59
  end
60
+ private_constant :CoercedNumber
61
61
  end
62
62
  end
@@ -6,20 +6,13 @@ module Mint
6
6
  # Converts the monetary amount to a {BigDecimal} object.
7
7
  #
8
8
  # @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
9
+ def to_d = amount.to_d 0
15
10
 
16
11
  # Converts the monetary amount to a standard float.
17
12
  # Note: Using float conversion loses precision guarantees.
18
13
  #
19
14
  # @return [Float] the floating-point representation of the money amount
20
- def to_f
21
- amount.to_f
22
- end
15
+ def to_f = amount.to_f
23
16
 
24
17
  # Renders a safe HTML5 `<data>` element containing the formatted currency.
25
18
  # Embeds the ISO currency description and raw value as the metadata `title` attribute.
@@ -29,15 +22,13 @@ module Mint
29
22
  def to_html(format = DEFAULT_FORMAT)
30
23
  title = Kernel.format("#{currency_code} %0.#{currency.subunit}f", amount)
31
24
  body = to_s(format: format)
32
- %(<data class='money' title='#{ERB::Util.html_escape(title)}'>#{ERB::Util.html_escape(body)}</data>)
25
+ %(<data class='money' title='#{title}'>#{ERB::Util.html_escape(body)}</data>)
33
26
  end
34
27
 
35
28
  # Truncates and converts the monetary amount to an Integer.
36
29
  #
37
30
  # @return [Integer] the integer representation of the money amount
38
- def to_i
39
- amount.to_i
40
- end
31
+ def to_i = amount.to_i
41
32
 
42
33
  def to_hash
43
34
  { currency: currency_code, amount: Kernel.format("%0.#{currency.subunit}f", amount) }
@@ -56,8 +47,6 @@ module Mint
56
47
  # Returns the exact internal Rational representation of the monetary amount.
57
48
  #
58
49
  # @return [Rational] the rational representation of the money amount
59
- def to_r
60
- amount
61
- end
50
+ def to_r = amount
62
51
  end
63
52
  end
@@ -1,13 +1,28 @@
1
1
  module Mint
2
2
  # Formatting functionality for Money objects
3
3
  class Money
4
+ # Keys accepted in the per-sign Hash form of `to_s(format:)`.
5
+ SIGN_FORMAT_KEYS = %i[positive negative zero].freeze
6
+
4
7
  # Formats money as a string with customizable format, thousand delimiter, and decimal
5
8
  #
6
- # @param format [String] Format string with placeholders: %<symbol>s, %<amount>f, %<currency>s
9
+ # @param format [String, Hash] Either a Format string with placeholders
10
+ # (%<symbol>s, %<amount>f, %<currency>s), or a Hash with per-sign keys
11
+ # (:positive, :negative, :zero) each holding a format string. A Hash
12
+ # is convenient for sign-aware formats such as accounting parentheses:
13
+ #
14
+ # money.to_s(format: { negative: '(%<symbol>s%<amount>f)' })
15
+ #
16
+ # Missing keys fall back to the module default, so a Hash with only
17
+ # :negative will still format positives sensibly. The valid keys are
18
+ # :positive, :negative, :zero; anything else raises ArgumentError.
7
19
  # @param thousand [String, false] Thousands delimiter (e.g., ',' for 1,000)
8
20
  # @param decimal [String] Decimal separator (e.g., '.' or ',')
9
21
  # @return [String] Formatted money string
10
22
  #
23
+ # @raise [ArgumentError] if +format+ is not a String or Hash, the Hash
24
+ # is empty, or the Hash contains an unrecognised key.
25
+ #
11
26
  # @example Basic formatting
12
27
  # money = Mint.money(1234.56, 'USD')
13
28
  # money.to_s #=> "$1,234.56"
@@ -20,6 +35,11 @@ module Mint
20
35
  # money.to_s(format: '%<amount>f %<symbol>s') #=> "1234.56 $"
21
36
  # money.to_s(format: '%<symbol>s%<amount>+f') #=> "$+1234.56"
22
37
  #
38
+ # @example Per-sign Hash format (accounting parentheses)
39
+ # loss = Mint.money(-1234.56, 'USD')
40
+ # loss.to_s(format: { negative: '(%<symbol>s%<amount>f)' }) #=> "($1,234.56)"
41
+ # Mint.money(0, 'BRL').to_s(format: { zero: '--' }) #=> "--"
42
+ #
23
43
  # @example Padding and alignment
24
44
  # money.to_s(format: '%<amount>10.2f') #=> " 1234.56"
25
45
  # money.to_s(format: '%<symbol>s%<amount>010.2f') #=> "$0001234.56"
@@ -27,6 +47,8 @@ module Mint
27
47
  def to_s(format: '%<symbol>s%<amount>f', decimal: '.', thousand: ',', width: nil)
28
48
  raise ArgumentError, 'Invalid format' unless format.is_a?(String) || format.is_a?(Hash)
29
49
 
50
+ validate_format_hash!(format) if format.is_a?(Hash)
51
+
30
52
  formatted = format_amount(format)
31
53
 
32
54
  formatted.tr!('.', decimal) if decimal != '.'
@@ -43,6 +65,17 @@ module Mint
43
65
 
44
66
  private
45
67
 
68
+ def validate_format_hash!(format)
69
+ raise ArgumentError, 'format Hash must not be empty' if format.empty?
70
+
71
+ unknown = format.keys - SIGN_FORMAT_KEYS
72
+ return if unknown.empty?
73
+
74
+ raise ArgumentError,
75
+ "Unknown format Hash key(s): #{unknown.inspect}. " \
76
+ "Valid keys are #{SIGN_FORMAT_KEYS.inspect}"
77
+ end
78
+
46
79
  def format_amount(format)
47
80
  format = { positive: format } if format.is_a?(String)
48
81
  value = amount
@@ -10,13 +10,41 @@ module Mint
10
10
  # @param amount [Numeric] The monetary amount
11
11
  # @param currency [Currency] The currency object
12
12
  # @raise [ArgumentError] If amount is not numeric or currency is invalid
13
- def initialize(amount, currency)
13
+ def self.create(amount, currency)
14
14
  raise ArgumentError, 'amount must be Numeric' unless amount.is_a?(Numeric)
15
- raise ArgumentError, 'currency must be a Currency object' unless currency.is_a?(Currency)
16
15
 
17
- @amount = amount.to_r.round(currency.subunit)
18
- @currency = currency
19
- freeze
16
+ checked_currency = Mint.currency(currency)
17
+ raise ArgumentError, "Currency not found (#{currency})" unless checked_currency
18
+
19
+ new(checked_currency.normalize_amount(amount), checked_currency)
20
+ end
21
+
22
+ # Builds a Money from a fractional (smallest-unit) Integer amount.
23
+ # This is the inverse of {#fractional}: for USD, the fractional unit is
24
+ # 1 cent; for JPY it is 1 yen; for IQD it is 1 dinar (subunit 3).
25
+ #
26
+ # @param fractional [Integer] the amount expressed in the currency's
27
+ # smallest unit (e.g. cents). Must be an Integer to preserve exactness.
28
+ # @param currency [String, Symbol, Currency] the currency identifier
29
+ # @return [Money] the resulting Money instance
30
+ # @raise [ArgumentError] if +fractional+ is not an Integer or +currency+
31
+ # is not registered
32
+ #
33
+ # @example USD cents
34
+ # Money.from_fractional(123_456, 'USD') #=> [USD 1234.56]
35
+ # @example JPY (subunit 0)
36
+ # Money.from_fractional(1234, 'JPY') #=> [JPY 1234]
37
+ # @example Round trip
38
+ # m = Mint.money(9.99, 'USD')
39
+ # Money.from_fractional(m.fractional, 'USD') == m #=> true
40
+ def self.from_fractional(fractional, currency)
41
+ raise ArgumentError, 'fractional must be an Integer' unless fractional.is_a?(Integer)
42
+
43
+ checked_currency = Mint.currency(currency)
44
+ raise ArgumentError, "Currency not found (#{currency})" unless checked_currency
45
+
46
+ amount = Rational(fractional, checked_currency.fractional_multiplier)
47
+ new(amount, checked_currency)
20
48
  end
21
49
 
22
50
  # Returns the ISO 3-letter currency code string.
@@ -35,6 +63,7 @@ module Mint
35
63
  # @param new_amount [Numeric] The new amount
36
64
  # @return [Money] A new Money object or self
37
65
  def mint(new_amount)
66
+ new_amount = currency.normalize_amount(new_amount)
38
67
  new_amount == amount ? self : Money.new(new_amount, currency)
39
68
  end
40
69
 
@@ -50,5 +79,69 @@ module Mint
50
79
  # @param other [Object] the target object to compare
51
80
  # @return [Boolean] true if currencies match, false otherwise
52
81
  def same_currency?(other) = other.respond_to?(:currency) && other.currency == currency
82
+
83
+ # Constrains +self+ to the inclusive range [+min+, +max+].
84
+ #
85
+ # Both bounds may be either a same-currency {Money} or a {Numeric}. A
86
+ # Numeric is interpreted as an amount in +self+'s currency, so the common
87
+ # pricing idiom +price.clamp(0, 100)+ reads as "0 to 100 in the same
88
+ # currency as +price+".
89
+ #
90
+ # When +self+ is already in range the receiver is returned (no new object
91
+ # allocated). When out of range, the nearest bound is returned as a new
92
+ # frozen {Money} in +self+'s currency.
93
+ #
94
+ # @param min [Money, Numeric] lower bound (inclusive)
95
+ # @param max [Money, Numeric] upper bound (inclusive)
96
+ # @return [Money] +self+ if in range, otherwise the nearer bound
97
+ # @raise [ArgumentError] if +min+ or +max+ is not a Money or Numeric; if
98
+ # a Money operand has a different currency; if +min+ > +max+
99
+ #
100
+ # @example In range
101
+ # Mint.money(5, 'USD').clamp(0, 10) #=> [USD 5.00] (returns self)
102
+ #
103
+ # @example Out of range, with Numeric bounds
104
+ # Mint.money(50, 'USD').clamp(0, 10) #=> [USD 10.00]
105
+ #
106
+ # @example Out of range, with Money bounds
107
+ # loss = Mint.money(-5, 'USD')
108
+ # floor = Mint.money(0, 'USD')
109
+ # ceil = Mint.money(10, 'USD')
110
+ # loss.clamp(floor, ceil) #=> [USD 0.00]
111
+ #
112
+ # @example Subunit-0 currency (JPY)
113
+ # Mint.money(500, 'JPY').clamp(0, 100) #=> [JPY 100]
114
+ def clamp(min, max)
115
+ case min
116
+ when Numeric
117
+ when Money
118
+ raise(ArgumentError, "min currency must be: #{currency_code}") unless same_currency?(min)
119
+
120
+ min = min.amount
121
+ else raise(ArgumentError, 'min must be Numeric or Money')
122
+ end
123
+
124
+ case max
125
+ when Numeric
126
+ when Money
127
+ raise(ArgumentError, "max currency must be: #{currency_code}") unless same_currency?(max)
128
+
129
+ max = max.amount
130
+ else raise(ArgumentError, 'max must be Numeric or Money')
131
+ end
132
+
133
+ mint(amount.clamp(min, max))
134
+ end
135
+
136
+ private
137
+
138
+ # Initializes a new Money object with the given amount and currency.
139
+ # @param amount [Numeric] The monetary amount
140
+ # @param currency [Currency] The currency object
141
+ def initialize(amount, currency)
142
+ @amount = amount
143
+ @currency = currency
144
+ freeze
145
+ end
53
146
  end
54
147
  end
@@ -25,7 +25,7 @@ module Mint
25
25
  currency = currency ? Mint.currency(currency) : parse_currency(input)
26
26
  raise ArgumentError, "Currency [#{currency}] not registered" unless currency
27
27
 
28
- amount = parse_amount(input)
28
+ amount = currency.normalize_amount(parse_amount(input))
29
29
  new(amount, currency)
30
30
  end
31
31
 
@@ -1,5 +1,5 @@
1
1
  # Root namespace for the Minting library.
2
2
  module Minting
3
3
  # Current version of the Minting gem.
4
- VERSION = '1.2.0'.freeze
4
+ VERSION = '1.4.0'.freeze
5
5
  end
data/minting.gemspec CHANGED
@@ -27,6 +27,7 @@ Gem::Specification.new do |s|
27
27
  }
28
28
 
29
29
  s.required_ruby_version = '>= 3.2.0'
30
+ s.add_dependency 'bigdecimal', '>= 4.0'
30
31
 
31
32
  s.files = Dir.glob('{bin,doc,lib}/**/*')
32
33
  s.files += %w[minting.gemspec Rakefile README.md LICENSE]
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minting
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gilson Ferraz
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bigdecimal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '4.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '4.0'
12
26
  description: Library to manipulate currency values
13
27
  email: []
14
28
  executables: []
@@ -20,6 +34,28 @@ files:
20
34
  - Rakefile
21
35
  - bin/console
22
36
  - bin/setup
37
+ - doc/Mint.html
38
+ - doc/Mint/Currency.html
39
+ - doc/Mint/Money.html
40
+ - doc/Minting.html
41
+ - doc/_index.html
42
+ - doc/agents/AGENTS.md
43
+ - doc/agents/copilot-instructions.md
44
+ - doc/agents/gemini_gem_evaluation.md
45
+ - doc/agents/recommendations.md
46
+ - doc/class_list.html
47
+ - doc/css/common.css
48
+ - doc/css/full_list.css
49
+ - doc/css/style.css
50
+ - doc/file.README.html
51
+ - doc/file_list.html
52
+ - doc/frames.html
53
+ - doc/index.html
54
+ - doc/js/app.js
55
+ - doc/js/full_list.js
56
+ - doc/js/jquery.js
57
+ - doc/method_list.html
58
+ - doc/top-level-namespace.html
23
59
  - lib/minting.rb
24
60
  - lib/minting/data/currencies.yaml
25
61
  - lib/minting/mint.rb