minting 2.0.0 → 2.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20bb9d0f4d0437f84ab790d3fdbd27650bee49d0b9d1231b3ba19888987583b4
4
- data.tar.gz: 8787d2359db2b7c75a2054fdcc6d56525e819f25b4d1cc746d2d6bcb01fd03ff
3
+ metadata.gz: 851c39a123584b8a782ef0c610c5e271239e0f20b948963bf926daf11defa269
4
+ data.tar.gz: 9d9c5aab7a6acc499308b2850157f6e8d4eef8ad01f4b0db4aa75e89067162fb
5
5
  SHA512:
6
- metadata.gz: 29d3c3b52a8f5786587caf516fce6828341ec10ca6dd57f2882b9fb6dda13ae136237d3fe087613d6a832553be05a5bb1fb3e77d54952d030fdca87a879923e2
7
- data.tar.gz: f5d8ef6c9bb9bffdbe9b7b84b94badb678d4a04a4648b63fa49b9e0086130d0abf73bd20988c6e544b7b154e671677ae0d06bc3dcc739ce410e034b5fedd53fe
6
+ metadata.gz: d6cca508a6ec6690436d67f70720db03bcebabeb7b561a4769aca306d9350f1d40ba07af63500316c236094dca13b7a6c181d49b324729e7439deff05f69513a
7
+ data.tar.gz: 419ce736af2fd00d90834dc6b82e5459ff83873922871a40d89d62dda69603ce31a5456918fa61ba21564489019eba6c80b1589cefce19841410a6e328ee45fa
data/README.md CHANGED
@@ -1,7 +1,10 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/minting.svg)](https://badge.fury.io/rb/minting)
2
2
  [![CI](https://github.com/gferraz/minting/actions/workflows/ci.yml/badge.svg)](https://github.com/gferraz/minting/actions/workflows/ci.yml)
3
- [![Test Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/gferraz/minting)
3
+ [![Test Coverage](https://img.shields.io/badge/coverage-94%25-brightgreen)](https://github.com/gferraz/minting)
4
4
  [![Documentation](https://img.shields.io/badge/docs-rubydoc.info-blue)](https://www.rubydoc.info/gems/minting/frames)
5
+ [![RubyCritic Score](https://img.shields.io/badge/RubyCritic-93/100-brightgreen)](https://github.com/gferraz/minting)
6
+ [![GitHub last commit](https://img.shields.io/github/last-commit/gferraz/minting)](https://github.com/gferraz/minting/commits/main)
7
+ [![License](https://img.shields.io/github/license/gferraz/minting)](https://github.com/gferraz/minting/blob/main/LICENSE)
5
8
 
6
9
  # Minting
7
10
 
@@ -9,7 +12,7 @@
9
12
 
10
13
 
11
14
 
12
- > **Status:** Minting 2.0 is released. The core API (`Money`, `Currency`, formatting, parsing) is stable.
15
+ > **Status:** Minting 2.1 is released. The core API (`Money`, `Currency`, formatting, parsing) is stable.
13
16
 
14
17
  ```ruby
15
18
  price = Money.from(19.99, 'USD') #=> [USD 19.99]
@@ -19,11 +22,84 @@ total = price + tax #=> [USD 21.59]
19
22
  total.to_s #=> "$21.59"
20
23
  ```
21
24
 
25
+ ## Quickstart
26
+
27
+ Get started in 60 seconds:
28
+
29
+ ```ruby
30
+ # Add to your Gemfile
31
+ gem "minting"
32
+
33
+ # Or install directly
34
+ # gem install minting
35
+
36
+ # Require the gem
37
+ require "minting"
38
+
39
+ # Create money objects
40
+ price = Money.from(19.99, "USD")
41
+ tax_rate = 0.08
42
+ tax = price * tax_rate
43
+ total = price + tax
44
+
45
+ # Format as a string
46
+ puts total.to_s # => "$21.59"
47
+
48
+ # Parse from a string
49
+ money = Money.parse("$15.99")
50
+ puts money # => "$15.99"
51
+
52
+ # Allocate money proportionally
53
+ total = Money.from(100, "USD")
54
+ shares = total.allocate([1, 2, 3]) # => [16.67, 33.33, 50.00]
55
+
56
+ # Split into N equal parts
57
+ parts = total.split(3) # => [33.34, 33.33, 33.33]
58
+ ```
59
+
60
+ ## What's New in 2.1
61
+
62
+ ### Breaking Changes
63
+ - Rounding mode symbols renamed to match `Rational#round` `half:` parameter: `:half_up` → `:up`, `:half_down` → `:down`, `:half_even` → `:even`
64
+
65
+ ## What's New in 2.0
66
+
67
+ ### Breaking Changes
68
+ - `Mint.parse` and `Mint.parse!` removed — use `Money.parse` and `Money.parse!`
69
+ - `Mint.with_rounding` removed — use `Money.with_rounding`
70
+ - `Mint.world_currencies` removed — use `Currency.world_currencies`
71
+ - `Money#mint` removed — use `Money#copy_with`
72
+ - `Money::Currency` is the canonical name to access the `Currency` class
73
+ - `minting/mint/aliases` abbreviated to `minting/aliases`
74
+ - `Money#format` `formatter_class:` kwarg removed — `Formatter` is now the sole formatter implementation
75
+ - `Money#to_json` and `Money.from_json` — moved to `attribute-money` companion gem
76
+
77
+ ### New Features
78
+ - **Crypto currency support**: Opt-in YAML-backed definitions for ~25 popular coins (BTC, ETH, SOL, ...). Use `Currency.register_crypto('BTC', 'ETH')` to register, or `Currency.crypto_currencies` to inspect available definitions.
79
+ - `Currency.registered_currencies` — public access to all registered currencies (frozen hash)
80
+ - `Money.from_hash(hash)` — deserializer symmetric with `to_hash`, accepts `{ currency:, amount: }`
81
+ - `Money#integral` — returns the whole-unit part of the amount (complement to `#fractional`). `#to_i` is now an alias of `#integral`.
82
+ - `%<dsymbol>s` format placeholder — uses `currency.disambiguate_symbol` (e.g. "US$", "C$", "A$") when available, falling back to the primary symbol.
83
+ - **Compiled formatting**: Formatting is now compiled into reusable lambdas at the class level — 1.4–2.2x formatting speedup depending on scenario.
84
+ - **Locale-aware formatting**: `locale:` kwarg on `Money#format` / `#to_fs`, supports per-locale decimal/thousand separators and format templates via `Mint.locale_backend`. Works seamlessly with Rails I18n.
85
+ - **Faster startup**: World currencies are now preloaded at gem initialization, eliminating lazy-loading overhead and mutex contention.
86
+
87
+ ### Bugfixes
88
+ - `Money#fractional` now returns a signed value matching the amount's sign (previously always positive for negative amounts). The invariant `integral * multiplier + fractional == subunits` now holds for all amounts.
89
+ - `Money#initialize` now calls `.to_r` on the amount, guaranteeing `@amount` is always a `Rational`. Fixes a hash/`eql?` contract violation for zero-subunit currencies and an `ArgumentError` in `Integer#to_d`.
90
+
91
+ ### Removed
92
+ - `Money::Formatter2` — removed; `Formatter` is the sole implementation
93
+ - `Money#to_json` and `Money.from_json` — moved to `attribute-money` companion gem
94
+
22
95
  Amounts are stored as `Rational`, so there's no floating-point drift — `0.1 + 0.2` problems simply don't happen here, at any scale.
23
96
 
24
97
  ## Table of contents
25
98
 
99
+ - [Quickstart](#quickstart)
26
100
  - [Why Minting](#why-minting)
101
+ - [What's New in 2.1](#whats-new-in-21)
102
+ - [What's New in 2.0](#whats-new-in-20)
27
103
  - [How it compares](#how-it-compares)
28
104
  - [Installation](#installation)
29
105
  - [Usage](#usage)
@@ -198,7 +274,7 @@ Notes:
198
274
 
199
275
  ```ruby
200
276
  # All registered currencies (150+ ISO 4217 + custom)
201
- Money::Currency.registered_currencies.size #=> 154
277
+ Money::Currency.registered_currencies.size #=> 164
202
278
  Money::Currency.registered_currencies.each { |code, c| puts "#{code}: #{c.name}" }
203
279
 
204
280
  # Built-in ISO 4217 currencies (before custom registrations)
@@ -254,16 +330,6 @@ Currency.crypto_currencies.each { |c| puts "#{c.code}: #{c.name}" }
254
330
  Currency.register_all_crypto # raises KeyError on any conflict
255
331
  ```
256
332
 
257
- `Currency.crypto_currencies` lists all available definitions without registering:
258
-
259
- ```ruby
260
- Currency.crypto_currencies.each { |c| puts "#{c.code}: #{c.name}" }
261
- # BTC: Bitcoin
262
- # ETH: Ethereum
263
- # SOL: Solana
264
- # ...
265
- ```
266
-
267
333
  ### Locale formatting
268
334
 
269
335
  Minting doesn't ship built-in locale data, but the `Mint.locale_backend` hook lets you wire in locale-specific decimal/thousand separators and format templates:
@@ -315,14 +381,14 @@ Mint.money(9.99, 'BRL').format #=> "R$9,99"
315
381
  **Rounding modes** — Wrap operations in `Money.with_rounding(mode)` to change how amounts are rounded to the subunit:
316
382
 
317
383
  ```ruby
318
- Money.with_rounding(:half_down) { Money.from(1.005, 'USD') } #=> [USD 1.00]
319
- Money.with_rounding(:ceil) { Money.from(1.001, 'USD') } #=> [USD 1.01]
320
- Money.with_rounding(:floor) { Money.parse('1.009', 'USD') } #=> [USD 1.00]
384
+ Money.with_rounding(:down) { Money.from(1.005, 'USD') } #=> [USD 1.00]
385
+ Money.with_rounding(:even) { Money.from(1.015, 'USD') } #=> [USD 1.02]
386
+ Money.with_rounding(:up) { Money.from(1.005, 'USD') } #=> [USD 1.01]
321
387
  ```
322
388
 
323
- Modes: `:half_up` (default), `:half_down`, `:floor`, `:ceil`, `:truncate`, `:down`. Applies to construction, parsing, `change`, `split`, and `allocate`. Restores the previous mode when the block exits, even on exception.
389
+ Modes: `:up` (default), `:down`, `:even`. Applies to construction, parsing, `change`, `split`, and `allocate`. Restores the previous mode when the block exits, even on exception.
324
390
 
325
- > **Performance note:** Rounding-mode support is not loaded by default — `require 'minting'` uses the fastest possible rounding (equivalent to `:half_up`) with zero dispatch overhead. The first call to `Money.with_rounding` loads the rounding module and patches `Currency#normalize_amount`, adding ~10–35 ns per money creation or mutation. If your application never uses custom rounding modes, there is **no performance cost**.
391
+ > **Performance note:** Rounding-mode support is not loaded by default — `require 'minting'` uses the fastest possible rounding (equivalent to `:up`) with zero dispatch overhead. The first call to `Money.with_rounding` activates the rounding dispatch in `Currency#normalize_amount`, adding ~10–35 ns per money creation or mutation. If your application never uses custom rounding modes, there is **no performance cost**.
326
392
 
327
393
  **Division** — `money / 5` returns new `Money`; `money / other_money` returns a numeric ratio, not money.
328
394
 
@@ -332,12 +398,11 @@ Modes: `:half_up` (default), `:half_down`, `:floor`, `:ceil`, `:truncate`, `:dow
332
398
 
333
399
  **Registered currencies** — `Currency.register(code:, subunit:, symbol:, priority:)` adds custom currencies. Only registered codes and symbols are recognized by the parser or searches. You don't need to register a currency to use it with most features.
334
400
 
335
- **Built-in currencies** — 150+ ISO-4217 world currencies ship in `lib/minting/data/currencies.yaml` and load when the registry is first accessed.
401
+ **Built-in currencies** — 150+ ISO-4217 world currencies ship in `lib/minting/data/world-currencies.yaml` and are preloaded at gem initialization.
336
402
 
337
- ## Optional top-level `Money` and `Currency`
403
+ ## Optional top-level `Money` (opt-out) and `Currency` (opt-in)
338
404
 
339
405
  By default, `require "minting"` exposes `Mint::Money` as the top-level `Money` constant, so you can write `Money.from(10, "USD")` directly:
340
-
341
406
  ```ruby
342
407
  require "minting"
343
408
 
@@ -372,13 +437,6 @@ Minting itself has no Rails dependency. For `ActiveRecord` type casting, validat
372
437
 
373
438
  - **[MoneyAttribute](https://github.com/gferraz/money-attribute)** — a `money_attribute` macro for models, with `ActiveRecord::Type` integration and `composed_of`-based support for multi-column (amount + currency) attributes.
374
439
 
375
- ## Roadmap
376
-
377
- Toward a stable 2.0: (no later than August 2026)
378
-
379
- - Exchange-rate conversion infrastructure (provider abstraction, stateless injection)
380
- - API surface review — locking in the public interface for `Money`, `Currency`, and formatting before the tag
381
-
382
440
  ## License
383
441
 
384
442
  MIT
@@ -6,19 +6,40 @@ module Mint
6
6
  # Currency objects are immutable and define the properties of a monetary unit
7
7
  # including its subunit precision, display symbol, and formatting rules.
8
8
  #
9
+ # Currency identity is defined by its ISO code — two Currency objects with
10
+ # the same +code+ are considered equal regardless of other attributes.
11
+ # The Registry guarantees that only one canonical Currency exists per code.
12
+ #
9
13
  # @see https://www.iso.org/iso-4217-currency-codes.html
10
- # @attr_reader code [String] ISO 4217 currency code (e.g., "USD", "EUR")
11
- # @attr_reader subunit [Integer] Number of decimal places (0 for JPY, 2 for USD, 3 for IQD)
12
- # @attr_reader symbol [String] Display symbol (e.g., "$", "€", "R$")
13
- # @attr_reader priority [Integer] Parser precedence for symbol detection
14
- # @attr_reader country [String, nil] Associated country code
15
- # @attr_reader name [String, nil] Currency name
16
- # @attr_reader fractional_multiplier [Integer] 10^subunit, used for fractional conversions
17
- # @attr_reader minimum_amount [Rational] Smallest representable amount (1/fractional_multiplier)
18
- # @attr_reader disambiguate_symbol [String, nil] A longer, code-prefixed variant to distinguish
19
- # currencies that share the same primary symbol (e.g. "US$" for USD, "C$" for CAD).
20
- Currency = Data.define(:code, :subunit, :symbol, :priority, :country, :name,
21
- :fractional_multiplier, :disambiguate_symbol) do
14
+ class Currency
15
+ # @return [String] ISO 4217 currency code (e.g., "USD", "EUR")
16
+ attr_reader :code
17
+
18
+ # @return [String, nil] Associated country code
19
+ attr_reader :country
20
+
21
+ # @return [String, nil] A longer, code-prefixed variant to distinguish
22
+ # currencies that share the same primary symbol (e.g. "US$" for USD, "C$" for CAD).
23
+ attr_reader :disambiguate_symbol
24
+
25
+ # @return [Integer] 10^subunit, used for fractional conversions
26
+ attr_reader :fractional_multiplier
27
+
28
+ # @return [Rational] Smallest representable amount (1/fractional_multiplier)
29
+ attr_reader :minimum_amount
30
+
31
+ # @return [String, nil] Currency name
32
+ attr_reader :name
33
+
34
+ # @return [Integer] Parser precedence for symbol detection
35
+ attr_reader :priority
36
+
37
+ # @return [Integer] Number of decimal places (0 for JPY, 2 for USD, 3 for IQD)
38
+ attr_reader :subunit
39
+
40
+ # @return [String, nil] Display symbol (e.g., "$", "€", "R$")
41
+ attr_reader :symbol
42
+
22
43
  # @param code [String] ISO 4217 currency code
23
44
  # @param symbol [String] Display symbol
24
45
  # @param subunit [Integer] Number of decimal places (default 0)
@@ -27,33 +48,54 @@ module Mint
27
48
  # @param name [String, nil] Currency name (default nil)
28
49
  def initialize(code:, symbol:, subunit: 0, priority: 0, country: nil, name: nil,
29
50
  disambiguate_symbol: nil)
30
- subunit = subunit.to_i
31
- priority = priority.to_i
32
- fractional_multiplier = 10**subunit
33
- symbol = nil if symbol && symbol.empty?
34
- disambiguate_symbol = nil if [code, symbol].include? disambiguate_symbol
35
- super(code:, subunit:, symbol:, priority:, country:, name:,
36
- fractional_multiplier:, disambiguate_symbol:)
51
+ @code = code
52
+ @country = country
53
+ @name = name
54
+ @priority = priority.to_i
55
+ @subunit = subunit.to_i
56
+ @symbol = symbol.nil? || symbol.empty? ? nil : symbol
57
+
58
+ @fractional_multiplier = 10**@subunit
59
+ @minimum_amount = Rational(1, @fractional_multiplier)
60
+ @disambiguate_symbol = [code, @symbol].include?(disambiguate_symbol) ? nil : disambiguate_symbol
61
+ freeze
37
62
  end
38
63
 
64
+ # Two Currency objects are equal if they share the same ISO code.
65
+ def ==(other) = other.is_a?(self.class) && code == other.code
66
+
67
+ # @return [String, nil] disambiguate_symbol or code/symbol fallback
68
+ def dsymbol = disambiguate_symbol || (Registry.symbol_shared?(symbol) ? code : symbol)
69
+
70
+ # Currency identity is by code — two objects with the same code are +eql?+
71
+ # regardless of other attributes. This makes Currency usable as a Hash key
72
+ # where lookup is by currency identity (ISO code).
73
+ def eql?(other) = other.is_a?(Currency) && code == other.code
74
+
75
+ # @return [Integer] stable hash based on currency code
76
+ def hash = code.hash
77
+
39
78
  # @return [String] debug representation
40
79
  def inspect = "<Currency:(#{code} #{symbol} #{subunit} #{name})>"
41
80
 
42
- # @return [Rational] smallest representable amount (1/fractional_multiplier)
43
- def minimum_amount = Rational(1, fractional_multiplier)
44
-
45
81
  # Normalizes a numeric amount for this currency.
46
82
  #
47
83
  # @param amount [Numeric] the monetary amount to normalize
48
84
  # @return [Rational] the amount converted to +Rational+ and rounded to
49
- # the currency's subunit precision (half-up by default)
85
+ # the currency's subunit precision (up by default)
50
86
  # @example
51
87
  # usd = Money::Currency.for_code('USD')
52
88
  # usd.normalize_amount(10.567) #=> (10567/1000)
53
89
  # usd.normalize_amount("5.25") #=> (21/4)
54
90
  #
55
- # @see Mint::Rounding.apply Custom rounding modes via {Money.with_rounding}
56
- def normalize_amount(amount) = amount.to_r.round(subunit)
91
+ # @see Money.with_rounding Custom rounding modes via {Money.with_rounding}
92
+ def normalize_amount(amount)
93
+ if Currency.custom_rounding_active?
94
+ amount.to_r.round(subunit, half: Thread.current[Currency::ROUNDING_THREAD_KEY])
95
+ else
96
+ amount.to_r.round(subunit)
97
+ end
98
+ end
57
99
 
58
100
  # Returns the cached frozen zero-Money for this currency.
59
101
  #
@@ -61,136 +103,5 @@ module Mint
61
103
  # @example
62
104
  # Money::Currency.for_code('USD').zero #=> [USD 0.00]
63
105
  def zero = Registry.zero_for(self)
64
-
65
- def dsymbol
66
- disambiguate_symbol || (Registry.symbol_shared?(symbol) ? code : symbol)
67
- end
68
- end
69
-
70
- # Registers a new currency, raising a KeyError if already registered.
71
- #
72
- # @param code [String] the unique currency code
73
- # @param subunit [Integer] the decimal subunit precision, defaults to 0
74
- # @param symbol [String] the display symbol
75
- # @param priority [Integer] parser precedence priority
76
- # @return [Currency] the newly registered Currency instance
77
- # @raise [ArgumentError] if the code contains invalid characters
78
- # @raise [KeyError] if the currency code is already registered
79
- def Currency.register(code:, subunit: 0, symbol: '', priority: 0)
80
- Registry.register(code:, subunit:, symbol:, priority:)
81
- end
82
-
83
- # Resolves an object into a {Currency}, returning +nil+ when it can't.
84
- #
85
- # Accepts +nil+, +String+, {Currency}, {Money}, or any object implementing
86
- # +#to_currency+ (must return {Currency}) or +#currency_code+ (must return +String+).
87
- #
88
- # @param object [String, Currency, Money, nil, #to_currency, #currency_code]
89
- # a currency code, object, or +nil+
90
- # @return [Currency, nil] the resolved Currency, or +nil+ if +object+ is +nil+
91
- # or the code is not registered
92
- # @raise [ArgumentError] if +object+ is an unsupported type, or if the method
93
- # used to resolve it returns a value of the wrong type
94
- def Currency.resolve(object)
95
- case object
96
- when NilClass then nil
97
- when Currency then object
98
- when Money then object.currency
99
- when String then Currency.for_code object
100
- else
101
- if object.respond_to?(:to_currency)
102
- result = object.to_currency
103
- unless result.is_a?(Currency)
104
- raise ArgumentError, "#to_currency must return a [Money::Currency], got #{result.class}"
105
- end
106
-
107
- result
108
- elsif object.respond_to?(:currency_code)
109
- result = object.currency_code
110
- raise ArgumentError, "#currency_code must return a [String], got #{result.class}" unless result.is_a?(String)
111
-
112
- Currency.for_code result
113
- else
114
- raise ArgumentError, "currency must be [Money::Currency], [Money], [String] or nil (#{object})"
115
- end
116
- end
117
- end
118
-
119
- # Resolves an object into a {Currency}, raising on failure.
120
- #
121
- # Like {.resolve} but raises when the result would be +nil+.
122
- #
123
- # @param object [String, Currency, Money, nil] a currency code, object, or +nil+
124
- # @return [Currency] the resolved Currency
125
- # @raise [Mint::UnknownCurrency] if +object+ cannot be resolved into a
126
- # registered currency. +Mint::UnknownCurrency+ inherits from +ArgumentError+,
127
- # so existing +rescue ArgumentError+ handlers continue to work.
128
- def Currency.resolve!(object)
129
- resolve(object) or raise Mint::UnknownCurrency, "Could not resolve (#{object}) into a currency"
130
- end
131
-
132
- # Returns all registered currencies as a frozen hash keyed by ISO code.
133
- #
134
- # @return [Hash{String => Currency}] frozen hash of all registered currencies
135
- # @example Iterate over registered currencies
136
- # Currency.registered_currencies.each { |code, currency| puts "#{code}: #{currency.name}" }
137
- # @example Count of registered currencies
138
- # Currency.registered_currencies.size #=> 154
139
- def Currency.registered_currencies = Registry.currencies
140
-
141
- # Looks up a registered currency by its alpha code.
142
- #
143
- # @param code [String] the currency code
144
- # @return [Currency, nil] the registered Currency, or +nil+ if not found
145
- def Currency.for_code(code)
146
- Registry.currencies[code]
147
- end
148
-
149
- # Looks up a currency by its display symbol.
150
- #
151
- # @param symbol [String] the display symbol (e.g. "$", "R$")
152
- # @return [Currency, nil] the highest-priority currency for the symbol
153
- def Currency.for_symbol(symbol)
154
- Registry.currency_for_symbol(symbol)
155
106
  end
156
-
157
- # Returns a zero {Money} in the given currency, useful as a default value
158
- # for discounts, totals, or placeholders.
159
- #
160
- # @param currency [String, Currency] a currency code or object
161
- # @return [Money] a frozen zero-Money
162
- # @raise [Mint::UnknownCurrency] if the currency can't be resolved
163
- def Currency.zero(currency) = Registry.zero_for(Currency.resolve!(currency))
164
-
165
- # Returns the frozen hash of all built-in ISO 4217 world currencies.
166
- #
167
- # @return [Hash{String => Currency}] ISO-4217 world currencies mapped by code
168
- # @api private
169
- def Currency.world_currencies = Registry.world_currencies
170
-
171
- # Returns the list of built-in crypto currency definitions.
172
- #
173
- # These are not registered by default — call {.register_crypto} to opt in.
174
- #
175
- # @return [Array<Currency>] frozen array of crypto currency definitions
176
- def Currency.crypto_currencies = Registry.crypto_currencies
177
-
178
- # Registers one or more crypto currencies into the shared currency registry.
179
- #
180
- # Raises on duplicate registration or unknown code — use +rescue+ if
181
- # idempotent bulk registration is needed.
182
- #
183
- # @param codes [Array<String>] one or more crypto currency codes
184
- # @raise [ArgumentError] if a code is not a known crypto currency
185
- # @raise [KeyError] if the currency code is already registered
186
- # @return [Array<Currency>] the newly registered Currency objects
187
- def Currency.register_crypto(...) = Registry.register_crypto(...)
188
-
189
- # Registers all built-in crypto currencies at once.
190
- #
191
- # Raises on the first duplicate — call +rescue+ if idempotency is needed.
192
- #
193
- # @raise [KeyError] if any currency code is already registered
194
- # @return [Array<Currency>] the newly registered Currency objects
195
- def Currency.register_all_crypto = Registry.register_all_crypto
196
107
  end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc:
4
+ module Mint
5
+ # Class-level methods on {Currency} that delegate to the {Registry}.
6
+ # Separated from the core class definition for readability.
7
+ #
8
+ # @api private
9
+
10
+ # Returns the list of built-in crypto currency definitions.
11
+ #
12
+ # These are not registered by default — call {.register_crypto} to opt in.
13
+ #
14
+ # @return [Array<Currency>] frozen array of crypto currency definitions
15
+ def Currency.crypto_currencies = Registry.crypto_currencies
16
+
17
+ # Looks up a registered currency by its alpha code.
18
+ #
19
+ # @param code [String] the currency code
20
+ # @return [Currency, nil] the registered Currency, or +nil+ if not found
21
+ def Currency.for_code(code) = Registry.currencies[code]
22
+
23
+ # Looks up a currency by its display symbol.
24
+ #
25
+ # @param symbol [String] the display symbol (e.g. "$", "R$")
26
+ # @return [Currency, nil] the highest-priority currency for the symbol
27
+ def Currency.for_symbol(symbol) = Registry.currency_for_symbol(symbol)
28
+
29
+ # Registers a new currency, raising a KeyError if already registered.
30
+ #
31
+ # @param code [String] the unique currency code
32
+ # @param subunit [Integer] the decimal subunit precision, defaults to 0
33
+ # @param symbol [String] the display symbol
34
+ # @param priority [Integer] parser precedence priority
35
+ # @return [Currency] the newly registered Currency instance
36
+ # @raise [ArgumentError] if the code contains invalid characters
37
+ # @raise [KeyError] if the currency code is already registered
38
+ def Currency.register(code:, subunit: 0, symbol: '', priority: 0)
39
+ Registry.register(code:, subunit:, symbol:, priority:)
40
+ end
41
+
42
+ # Registers all built-in crypto currencies at once.
43
+ #
44
+ # Raises on the first duplicate — call +rescue+ if idempotency is needed.
45
+ #
46
+ # @raise [KeyError] if any currency code is already registered
47
+ # @return [Array<Currency>] the newly registered Currency objects
48
+ def Currency.register_all_crypto = Registry.register_all_crypto
49
+
50
+ # Registers one or more crypto currencies into the shared currency registry.
51
+ #
52
+ # Raises on duplicate registration or unknown code — use +rescue+ if
53
+ # idempotent bulk registration is needed.
54
+ #
55
+ # @param codes [Array<String>] one or more crypto currency codes
56
+ # @raise [ArgumentError] if a code is not a known crypto currency
57
+ # @raise [KeyError] if the currency code is already registered
58
+ # @return [Array<Currency>] the newly registered Currency objects
59
+ def Currency.register_crypto(...) = Registry.register_crypto(...)
60
+
61
+ # Returns all registered currencies as a frozen hash keyed by ISO code.
62
+ #
63
+ # @return [Hash{String => Currency}] frozen hash of all registered currencies
64
+ # @example Iterate over registered currencies
65
+ # Currency.registered_currencies.each { |code, currency| puts "#{code}: #{currency.name}" }
66
+ # @example Count of registered currencies
67
+ # Currency.registered_currencies.size #=> 154
68
+ def Currency.registered_currencies = Registry.currencies
69
+
70
+ # Resolves an object into a {Currency}, returning +nil+ when it can't.
71
+ #
72
+ # Accepts +nil+, +String+, {Currency}, {Money}, or any object implementing
73
+ # +#to_currency+ (must return {Currency}) or +#currency_code+ (must return +String+).
74
+ #
75
+ # @param object [String, Currency, Money, nil, #to_currency, #currency_code]
76
+ # a currency code, object, or +nil+
77
+ # @return [Currency, nil] the resolved Currency, or +nil+ if +object+ is +nil+
78
+ # or the code is not registered
79
+ # @raise [ArgumentError] if +object+ is an unsupported type, or if the method
80
+ # used to resolve it returns a value of the wrong type
81
+ def Currency.resolve(object)
82
+ case object
83
+ when NilClass then nil
84
+ when Currency then object
85
+ when Money then object.currency
86
+ when String then Currency.for_code object
87
+ else
88
+ if object.respond_to?(:to_currency)
89
+ result = object.to_currency
90
+ unless result.is_a?(Currency)
91
+ raise ArgumentError, "#to_currency must return a [Money::Currency], got #{result.class}"
92
+ end
93
+
94
+ result
95
+ elsif object.respond_to?(:currency_code)
96
+ result = object.currency_code
97
+ raise ArgumentError, "#currency_code must return a [String], got #{result.class}" unless result.is_a?(String)
98
+
99
+ Currency.for_code result
100
+ else
101
+ raise ArgumentError, "currency must be [Money::Currency], [Money], [String] or nil (#{object})"
102
+ end
103
+ end
104
+ end
105
+
106
+ # Resolves an object into a {Currency}, raising on failure.
107
+ #
108
+ # Like {.resolve} but raises when the result would be +nil+.
109
+ #
110
+ # @param object [String, Currency, Money, nil] a currency code, object, or +nil+
111
+ # @return [Currency] the resolved Currency
112
+ # @raise [Mint::UnknownCurrency] if +object+ cannot be resolved into a
113
+ # registered currency. +Mint::UnknownCurrency+ inherits from +ArgumentError+,
114
+ # so existing +rescue ArgumentError+ handlers continue to work.
115
+ def Currency.resolve!(object)
116
+ resolve(object) or raise Mint::UnknownCurrency, "Could not resolve (#{object}) into a currency"
117
+ end
118
+
119
+ # Returns a zero {Money} in the given currency, useful as a default value
120
+ # for discounts, totals, or placeholders.
121
+ #
122
+ # @param currency [String, Currency] a currency code or object
123
+ # @return [Money] a frozen zero-Money
124
+ # @raise [Mint::UnknownCurrency] if the currency can't be resolved
125
+ def Currency.zero(currency) = Registry.zero_for(Currency.resolve!(currency))
126
+
127
+ # --- @api private ---
128
+
129
+ # Returns the frozen hash of all built-in ISO 4217 world currencies.
130
+ #
131
+ # @return [Hash{String => Currency}] ISO-4217 world currencies mapped by code
132
+ # @api private
133
+ def Currency.world_currencies = Registry.world_currencies
134
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc:
4
+ module Mint
5
+ # :nodoc:
6
+ class Currency
7
+ # @api private
8
+ VALID_ROUNDING_MODES = %i[up down even].freeze
9
+
10
+ # @api private
11
+ ROUNDING_THREAD_KEY = :minting_rounding_mode
12
+
13
+ # @return [Boolean] whether a custom rounding mode has been activated
14
+ # @api private
15
+ def self.custom_rounding_active? = @custom_rounding_active
16
+
17
+ # Activates the custom rounding dispatch path in {#normalize_amount}.
18
+ # Once called, this cannot be reversed for the lifetime of the process.
19
+ # @api private
20
+ def self.activate_custom_rounding! = @custom_rounding_active = true
21
+
22
+ # Returns the currently active rounding mode, falling back to +:up+.
23
+ # @api private
24
+ # @return [Symbol] one of +:up+, +:down+, +:even+
25
+ def self.current_rounding_mode
26
+ Thread.current[ROUNDING_THREAD_KEY] || :up
27
+ end
28
+
29
+ # Sets a rounding mode for the duration of a block, restoring the
30
+ # previous mode on exit (even on exception).
31
+ # @api private
32
+ # @param mode [Symbol] one of +:up+, +:down+, +:even+
33
+ # @yield block to execute with the mode active
34
+ # @raise [ArgumentError] on unknown mode
35
+ def self.rounding_mode(mode)
36
+ unless VALID_ROUNDING_MODES.include?(mode)
37
+ raise ArgumentError, "Unknown rounding mode: #{mode} (expected :up, :down, or :even)"
38
+ end
39
+
40
+ prev = Thread.current[ROUNDING_THREAD_KEY]
41
+ Thread.current[ROUNDING_THREAD_KEY] = mode
42
+ yield
43
+ ensure
44
+ Thread.current[ROUNDING_THREAD_KEY] = prev
45
+ end
46
+ end
47
+ end
@@ -14,25 +14,23 @@ module Mint
14
14
 
15
15
  private_constant :MUTEX
16
16
 
17
+ # Preload world currencies from YAML file during module load.
18
+ path = File.join(File.expand_path('../../data', __dir__), 'world-currencies.yaml')
19
+ @world_currencies = YAML.load_file(path).to_h do |entry|
20
+ [entry['code'], Currency.new(**entry.transform_keys(&:to_sym))]
21
+ end
22
+ @currencies = @world_currencies.freeze.dup.freeze
23
+
17
24
  # Loads ISO world currencies from YAML file.
18
25
  #
19
26
  # @return [Hash{String => Currency}] ISO-4217 world currencies mapped by code
20
27
  # @api private
21
- def self.world_currencies
22
- @world_currencies || MUTEX.synchronize do
23
- @world_currencies = begin
24
- path = File.join(File.expand_path('../../data', __dir__), 'world-currencies.yaml')
25
- YAML.load_file(path).to_h { |entry| [entry['code'], Currency.new(**entry.transform_keys(&:to_sym))] }
26
- end.freeze
27
- end
28
- end
28
+ def self.world_currencies = @world_currencies
29
29
 
30
30
  # Returns the frozen hash of all registered currencies (world + custom).
31
31
  #
32
32
  # @return [Hash{String => Currency}] registered currencies mapped by code
33
33
  # @api private
34
- def self.currencies
35
- @currencies || MUTEX.synchronize { @currencies = world_currencies.dup.freeze }
36
- end
34
+ def self.currencies = @currencies
37
35
  end
38
36
  end
data/lib/minting/mint.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'currency/currency'
4
+ require_relative 'currency/registry'
5
+ require_relative 'currency/rounding'
4
6
 
5
7
  require_relative 'mint/dsl/numeric'
6
8
  require_relative 'mint/dsl/range'
@@ -15,7 +15,7 @@ module Mint
15
15
  # money = Money.from(10.00, 'USD')
16
16
  # money.split(3) #=> [[USD 3.34], [USD 3.33], [USD 3.33]]
17
17
  def split(slices)
18
- raise ArgumentError, 'Slices quantity must be an poitive integer' unless slices.positive? && slices.integer?
18
+ raise ArgumentError, 'Slices quantity must be a positive integer' unless slices.positive? && slices.integer?
19
19
 
20
20
  fraction = currency.normalize_amount(amount / slices)
21
21
  allocate_left_over(amounts: Array.new(slices, fraction),
@@ -46,11 +46,13 @@ module Mint
46
46
  amount = money.amount
47
47
  currency = money.currency
48
48
 
49
- template = @templates[amount <=> 0] || @positive_template
50
- display_amount = template == @negative_template ? -amount : amount
49
+ templates = @has_placeholder ? @templates_by_subunit[currency.subunit] : @templates
50
+
51
+ template = templates[amount <=> 0] || templates[1]
52
+
53
+ display_amount = @has_negative_template && amount < 0 ? -amount : amount
51
54
  integral = display_amount.to_i
52
55
 
53
- template = template.gsub(SUBUNIT_PLACEHOLDER, currency.subunit.to_s) if @has_placeholder
54
56
  result = Kernel.format(template,
55
57
  currency: currency.code,
56
58
  dsymbol: @needs_dsymbol && currency.dsymbol,
@@ -86,16 +88,22 @@ module Mint
86
88
  # The placeholder is later replaced with the actual subunit count at
87
89
  # format time (e.g. "\uE000" → "2" for USD, "0" for JPY).
88
90
  @templates.transform_values! { |f| f.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{SUBUNIT_PLACEHOLDER}f") }
89
- @negative_template = @templates[-1]
90
- @positive_template = @templates[1]
91
-
92
- joined_template = @templates.values.join
93
- @has_placeholder = joined_template.include?(SUBUNIT_PLACEHOLDER)
94
- @needs_fractional = joined_template.include?('%<fractional>')
95
- @needs_dsymbol = joined_template.include?('%<dsymbol>')
96
- @needs_thousand_substitution = @thousand && !@thousand.empty? && (joined_template.include?('%<amount>') ||
97
- joined_template.include?('%<integral>'))
91
+ @has_negative_template = @templates.key?(-1)
92
+
93
+ joined = @templates.values.join
94
+ @needs_fractional = joined.include?('%<fractional>')
95
+ @needs_dsymbol = joined.include?('%<dsymbol>')
96
+
97
+ @needs_thousand_substitution = @thousand && !@thousand.empty? &&
98
+ (joined.include?('%<amount>') || joined.include?('%<integral>'))
98
99
  @thousand_replacement = "\\1#{@thousand}" if @needs_thousand_substitution
100
+
101
+ @has_placeholder = joined.include?(SUBUNIT_PLACEHOLDER)
102
+ return unless @has_placeholder
103
+
104
+ @templates_by_subunit = Hash.new do |h, subunit|
105
+ h[subunit] = @templates.transform_values { |f| f.gsub(SUBUNIT_PLACEHOLDER, subunit.to_s) }
106
+ end
99
107
  end
100
108
  end
101
109
  end
@@ -9,19 +9,18 @@ module Mint
9
9
  # Restores the previous mode (or default) when the block exits, even on
10
10
  # exception.
11
11
  #
12
- # Rounding-mode support is loaded lazily on first call. Once loaded,
13
- # +Currency#normalize_amount+ is patched to dispatch through the
14
- # rounding module, adding ~10–35&ns of overhead to every money creation
15
- # or mutation. When rounding modes are never used (the common case),
16
- # the fast path incurs zero overhead.
12
+ # Rounding-mode support is activated on first call. Once activated,
13
+ # +Currency#normalize_amount+ dispatches through +Currency.rounding_mode+,
14
+ # adding ~10–35&ns of overhead to every money creation or mutation.
15
+ # When rounding modes are never used (the common case), the fast path
16
+ # incurs zero overhead.
17
17
  #
18
- # @param mode [Symbol] one of: +:half_up+, +:half_down+, +:floor+,
19
- # +:ceil+, +:truncate+, +:down+
18
+ # @param mode [Symbol] one of: +:up+, +:down+, +:even+
20
19
  # @yield block to execute with the rounding mode active
21
20
  # @raise [ArgumentError] if +mode+ is not a recognised rounding mode
22
21
  def self.with_rounding(mode, &)
23
- require_relative '../mint/rounding' unless defined?(Mint::Rounding)
24
- Mint::Rounding.with_mode(mode, &)
22
+ Currency.activate_custom_rounding!
23
+ Currency.rounding_mode(mode, &)
25
24
  end
26
25
  end
27
26
  end
@@ -3,5 +3,5 @@
3
3
  # Root namespace for the Minting library.
4
4
  module Minting
5
5
  # Current version of the Minting gem.
6
- VERSION = '2.0.0'
6
+ VERSION = '2.1.0'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minting
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gilson Ferraz
@@ -46,6 +46,8 @@ files:
46
46
  - lib/minting.rb
47
47
  - lib/minting/aliases.rb
48
48
  - lib/minting/currency/currency.rb
49
+ - lib/minting/currency/registry.rb
50
+ - lib/minting/currency/rounding.rb
49
51
  - lib/minting/data/crypto-currencies.yaml
50
52
  - lib/minting/data/world-currencies.yaml
51
53
  - lib/minting/mint.rb
@@ -59,7 +61,6 @@ files:
59
61
  - lib/minting/mint/registry/registry.rb
60
62
  - lib/minting/mint/registry/symbols.rb
61
63
  - lib/minting/mint/registry/zeros.rb
62
- - lib/minting/mint/rounding.rb
63
64
  - lib/minting/money/allocation/allocation.rb
64
65
  - lib/minting/money/allocation/split.rb
65
66
  - lib/minting/money/arithmetics/methods.rb
@@ -103,7 +104,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
103
104
  - !ruby/object:Gem::Version
104
105
  version: '0'
105
106
  requirements: []
106
- rubygems_version: 4.0.10
107
+ rubygems_version: 4.0.17
107
108
  specification_version: 4
108
109
  summary: Library to manipulate currency values
109
110
  test_files: []
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mint
4
- # Rounding-mode dispatch table and block-scoped context.
5
- # @api private
6
- module Rounding
7
- # Maps mode symbols to their corresponding +Rational+ rounding lambdas.
8
- # @return [Hash{Symbol => Proc}]
9
- # @api private
10
- MODES = {
11
- half_up: ->(amount, ndigits) { amount.round(ndigits, half: :up) },
12
- half_down: ->(amount, ndigits) { amount.round(ndigits, half: :down) },
13
- half_even: ->(amount, ndigits) { amount.round(ndigits, half: :even) },
14
- floor: ->(amount, ndigits) { amount.floor(ndigits) },
15
- ceil: ->(amount, ndigits) { amount.ceil(ndigits) },
16
- truncate: ->(amount, ndigits) { amount.truncate(ndigits) },
17
- down: ->(amount, ndigits) { amount.truncate(ndigits) }
18
- }.freeze
19
-
20
- THREAD_KEY = :minting_rounding_mode
21
-
22
- # Returns the currently active rounding mode, falling back to +:half_up+.
23
- # @api private
24
- # @return [Symbol]
25
- def self.current_mode
26
- Thread.current[THREAD_KEY] || :half_up
27
- end
28
-
29
- # Rounds +amount+ to +ndigits+ using the currently scoped rounding mode.
30
- # Uses the fast path (+to_r.round+) when no custom mode is active.
31
- # @api private
32
- # @param amount [Numeric]
33
- # @param ndigits [Integer]
34
- # @return [Rational]
35
- def self.apply(amount, ndigits)
36
- mode = Thread.current[THREAD_KEY]
37
- if mode
38
- MODES.fetch(mode).call(amount.to_r, ndigits)
39
- else
40
- amount.to_r.round(ndigits)
41
- end
42
- end
43
-
44
- # Sets a rounding mode for the duration of a block, restoring the
45
- # previous mode on exit (even on exception).
46
- # @api private
47
- # @param mode [Symbol]
48
- # @yield block to execute with the mode active
49
- # @raise [ArgumentError] on unknown mode
50
- def self.with_mode(mode)
51
- raise ArgumentError, "Unknown rounding mode: #{mode}" unless MODES.key?(mode)
52
-
53
- prev = Thread.current[THREAD_KEY]
54
- Thread.current[THREAD_KEY] = mode
55
- yield
56
- ensure
57
- Thread.current[THREAD_KEY] = prev
58
- end
59
- end
60
- end
61
-
62
- # When loaded, patch Currency to delegate rounding through this module.
63
- # Remove the default fast-path method first to avoid redefinition warnings.
64
- Money::Currency.remove_method(:normalize_amount)
65
- Money::Currency.define_method(:normalize_amount) do |amount|
66
- Mint::Rounding.apply(amount, subunit)
67
- end