minting 1.3.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.
@@ -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
@@ -14,14 +14,39 @@ module Mint
14
14
  raise ArgumentError, 'amount must be Numeric' unless amount.is_a?(Numeric)
15
15
 
16
16
  checked_currency = Mint.currency(currency)
17
- unless checked_currency
18
- raise ArgumentError,
19
- "Currency not found (#{currency}). Check Mint.currencies"
20
- end
17
+ raise ArgumentError, "Currency not found (#{currency})" unless checked_currency
21
18
 
22
19
  new(checked_currency.normalize_amount(amount), checked_currency)
23
20
  end
24
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)
48
+ end
49
+
25
50
  # Returns the ISO 3-letter currency code string.
26
51
  #
27
52
  # @return [String] the ISO currency code
@@ -38,7 +63,7 @@ module Mint
38
63
  # @param new_amount [Numeric] The new amount
39
64
  # @return [Money] A new Money object or self
40
65
  def mint(new_amount)
41
- new_amount = new_amount.to_r.round(currency.subunit)
66
+ new_amount = currency.normalize_amount(new_amount)
42
67
  new_amount == amount ? self : Money.new(new_amount, currency)
43
68
  end
44
69
 
@@ -55,6 +80,59 @@ module Mint
55
80
  # @return [Boolean] true if currencies match, false otherwise
56
81
  def same_currency?(other) = other.respond_to?(:currency) && other.currency == currency
57
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
+
58
136
  private
59
137
 
60
138
  # Initializes a new Money object with the given amount and currency.
@@ -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.3.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.3.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