minting 1.9.6 → 2.0.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +244 -121
  3. data/Rakefile +17 -26
  4. data/bin/bench_check +2 -2
  5. data/doc/agents/api_review-2026-06-15.md +1 -1
  6. data/doc/agents/copilot-instructions.md +5 -5
  7. data/doc/agents/expired/copilot-instructions.md +2 -2
  8. data/doc/agents/expired/gemini_gem_evaluation.md +2 -2
  9. data/lib/minting/aliases.rb +22 -0
  10. data/lib/minting/currency/currency.rb +87 -12
  11. data/lib/minting/data/crypto-currencies.yaml +126 -0
  12. data/lib/minting/data/world-currencies.yaml +75 -23
  13. data/lib/minting/mint/i18n.rb +79 -29
  14. data/lib/minting/mint/mint.rb +1 -26
  15. data/lib/minting/mint/registry/crypto.rb +59 -0
  16. data/lib/minting/mint/registry/registration.rb +1 -2
  17. data/lib/minting/mint/registry/symbols.rb +37 -30
  18. data/lib/minting/mint/rounding.rb +10 -7
  19. data/lib/minting/mint.rb +1 -2
  20. data/lib/minting/money/allocation/allocation.rb +2 -2
  21. data/lib/minting/money/allocation/split.rb +1 -1
  22. data/lib/minting/money/arithmetics/operators.rb +10 -13
  23. data/lib/minting/money/clamp.rb +6 -6
  24. data/lib/minting/money/coercion.rb +1 -1
  25. data/lib/minting/money/comparable.rb +3 -3
  26. data/lib/minting/money/constructors.rb +3 -42
  27. data/lib/minting/money/conversion.rb +23 -18
  28. data/lib/minting/money/format/format.rb +100 -0
  29. data/lib/minting/money/format/formatter.rb +102 -0
  30. data/lib/minting/money/format/to_s.rb +34 -99
  31. data/lib/minting/money/format/validator.rb +34 -0
  32. data/lib/minting/money/money.rb +25 -13
  33. data/lib/minting/money/parse.rb +127 -0
  34. data/lib/minting/money/rounding.rb +27 -0
  35. data/lib/minting/version.rb +1 -1
  36. data/lib/minting.rb +17 -8
  37. metadata +9 -28
  38. data/doc/Mint/Currency.html +0 -1943
  39. data/doc/Mint/CurrencyRegistry.html +0 -511
  40. data/doc/Mint/Money.html +0 -4607
  41. data/doc/Mint/RangeStepPatch.html +0 -277
  42. data/doc/Mint/Registry.html +0 -863
  43. data/doc/Mint/Rounding.html +0 -506
  44. data/doc/Mint/UnknownCurrency.html +0 -136
  45. data/doc/Mint.html +0 -1004
  46. data/doc/Minting.html +0 -142
  47. data/doc/_index.html +0 -180
  48. data/doc/class_list.html +0 -54
  49. data/doc/css/common.css +0 -1
  50. data/doc/css/full_list.css +0 -206
  51. data/doc/css/style.css +0 -1089
  52. data/doc/file.README.html +0 -277
  53. data/doc/file_list.html +0 -59
  54. data/doc/frames.html +0 -22
  55. data/doc/index.html +0 -277
  56. data/doc/js/app.js +0 -801
  57. data/doc/js/full_list.js +0 -334
  58. data/doc/js/jquery.js +0 -4
  59. data/doc/method_list.html +0 -686
  60. data/doc/top-level-namespace.html +0 -151
  61. data/lib/minting/mint/aliases.rb +0 -16
  62. data/lib/minting/mint/parser/parser.rb +0 -97
  63. data/lib/minting/mint/parser/separators.rb +0 -41
  64. data/lib/minting/money/format/formatting.rb +0 -97
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51f0a76084db98e53f29af4f82f1284f749c9691727a7a84a7f8cc758d1a9f67
4
- data.tar.gz: f24a130df454ee80f923aa42fd995e4b3eaf2f4f16d1a6f3a9e84548c08a4988
3
+ metadata.gz: 20bb9d0f4d0437f84ab790d3fdbd27650bee49d0b9d1231b3ba19888987583b4
4
+ data.tar.gz: 8787d2359db2b7c75a2054fdcc6d56525e819f25b4d1cc746d2d6bcb01fd03ff
5
5
  SHA512:
6
- metadata.gz: 42ed5e90ba4a936426344cfd1f8aad69755038704c53b67d8f714ced040661e2f37829aa44d2d67f12575596656b7d9bd5b80058995b57875aaa46b2cf6dd291
7
- data.tar.gz: a6490a3356b949df6889e72ee3fc885a4e10af8d14e09591d7bd615ea468371c30b1695b73e996cb91526ab824a610e2667797eb24e94144ec46ddd22dc42395
6
+ metadata.gz: 29d3c3b52a8f5786587caf516fce6828341ec10ca6dd57f2882b9fb6dda13ae136237d3fe087613d6a832553be05a5bb1fb3e77d54952d030fdca87a879923e2
7
+ data.tar.gz: f5d8ef6c9bb9bffdbe9b7b84b94badb678d4a04a4648b63fa49b9e0086130d0abf73bd20988c6e544b7b154e671677ae0d06bc3dcc739ce410e034b5fedd53fe
data/README.md CHANGED
@@ -1,41 +1,68 @@
1
- # Minting
2
-
3
- Fast, precise, and developer-friendly money handling for Ruby.
4
-
5
1
  [![Gem Version](https://badge.fury.io/rb/minting.svg)](https://badge.fury.io/rb/minting)
6
2
  [![CI](https://github.com/gferraz/minting/actions/workflows/ci.yml/badge.svg)](https://github.com/gferraz/minting/actions/workflows/ci.yml)
7
3
  [![Test Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/gferraz/minting)
8
4
  [![Documentation](https://img.shields.io/badge/docs-rubydoc.info-blue)](https://www.rubydoc.info/gems/minting/frames)
9
5
 
10
- ## Quick start
6
+ # Minting
11
7
 
12
- ```ruby
13
- require 'minting'
8
+ **Fast, precise, and developer-friendly money handling for Ruby.**
14
9
 
15
- price = Mint.money(19.99, 'USD') #=> [USD 19.99]
10
+
11
+
12
+ > **Status:** Minting 2.0 is released. The core API (`Money`, `Currency`, formatting, parsing) is stable.
13
+
14
+ ```ruby
15
+ price = Money.from(19.99, 'USD') #=> [USD 19.99]
16
16
  tax = price * 0.08 #=> [USD 1.60]
17
17
  total = price + tax #=> [USD 21.59]
18
18
 
19
19
  total.to_s #=> "$21.59"
20
- total.currency_code #=> "USD"
21
20
  ```
22
21
 
23
- ### Exact precision
24
- Amounts are stored as `Rational` and rounded to the currency subunit. No floating-point surprises, ever.
22
+ 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
+
24
+ ## Table of contents
25
25
 
26
- ### Blazing performance
27
- Minting is faster than the Money gem for everyday operations and **over 10× faster for formatting**. See full benchmarks in the [Performance Guide](test/performance/BENCHMARKS.md).
26
+ - [Why Minting](#why-minting)
27
+ - [How it compares](#how-it-compares)
28
+ - [Installation](#installation)
29
+ - [Usage](#usage)
30
+ - [Creating & comparing money](#creating--comparing-money)
31
+ - [Formatting](#formatting)
32
+ - [Integral & fractional parts](#integral--fractional-parts)
33
+ - [Parsing strings](#parsing-strings)
34
+ - [Currency lookup](#currency-lookup)
35
+ - [Crypto currencies](#crypto-currencies)
36
+ - [Locale formatting](#locale-formatting)
37
+ - [API notes](#api-notes)
38
+ - [Optional top-level `Money` and `Currency`](#optional-top-level-money-and-currency)
39
+ - [Rails integration](#rails-integration)
40
+ - [Roadmap](#roadmap)
41
+ - [License](#license)
28
42
 
29
- ### Clean, modern API
30
- Intuitive interface, descriptive error messages, and sensible defaults. Works the way you expect.
43
+ ## Why Minting
31
44
 
32
- ### Rails-ready
33
- Use with the [attribute-money](https://github.com/gferraz/attribute-money) companion gem for drop-in ActiveRecord type casting, validators, and form helpers.
45
+ Minting started as a personal project to learn what it actually takes to build and maintain a real open source Ruby gem — not exactly as a reaction against any existing library. That origin shows in how it's built: it's grown deliberately, with an emphasis on correctness and a clean API.
34
46
 
35
- ### Quality code
36
- - **100% test coverage** — every line exercised
37
- - **94/100 RubyCritic score** — clean, maintainable code
38
- - **CI-tested on Ruby 3.3 and 4.0**
47
+ What it's become along the way:
48
+
49
+ - **Exact by construction** — amounts are `Rational` internally, rounded to the currency's subunit only when needed. No silent precision loss from repeated arithmetic.
50
+ - **No Rails dependency** — Minting is a plain Ruby gem. Use it in a script, a Sinatra app, a background job runner, or a Rails app — your choice, not the gem's.
51
+ - **Formatting that doesn't fight you** — `Kernel.format`-style templates, named presets (`:accounting`, `:european`), per-sign formats (parentheses for negatives), and a pluggable locale hook.
52
+ - **Built for real-world currency handling** — 150+ ISO-4217 currencies, correct subunit handling (JPY has none, KWD has three), proportional allocation/split that doesn't lose cents to rounding.
53
+ - **Measured, not assumed, performance** — see the [Performance Guide](bench/BENCHMARKS.md) for actual benchmarks rather than claims.
54
+ - **Rails-ready without being Rails-only** — pair with the companion [MoneyAttribute](https://github.com/gferraz/money-attribute) gem for `ActiveRecord` type casting, validators, and form helpers.
55
+
56
+ ## How it compares
57
+
58
+ A few structural differences from the `money` gem (and `money-rails`), for anyone evaluating both:
59
+
60
+ | | **Minting** | **Money** |
61
+ |---|---|---|
62
+ | Internal representation | `Rational` | `BigDecimal` (float-backed input coercion) |
63
+ | Rails integration | via `money_attribute` gem | via `money-rails` gem |
64
+ | Exchange rates | Pluggable provider architecture *(planned)* | Built-in bank/exchange abstraction |
65
+ | Currency data | Ships with the gem | Ships with the gem |
39
66
 
40
67
  ## Installation
41
68
 
@@ -51,172 +78,259 @@ gem 'minting'
51
78
 
52
79
  ## Usage
53
80
 
81
+ ### Creating & comparing money
82
+
54
83
  ```ruby
55
84
  require 'minting'
56
85
 
57
- # Create money
58
- ten = Mint.money(10, 'USD') #=> [USD 10.00]
86
+ ten = Money.from(10, 'USD') #=> [USD 10.00]
59
87
 
60
- 1.dollar == Mint.money(1, 'USD') #=> true
61
- ten = 10.dollars #=> [USD 10.00]
62
- 4.to_money('USD') #=> [USD 4.00]
88
+ 1.dollar == Money.from(1, 'USD') #=> true
89
+ ten = 10.dollars #=> [USD 10.00]
90
+ 4.to_money('USD') #=> [USD 4.00]
63
91
 
64
92
  # Comparisons
65
- ten == 10.dollars #=> true
66
- ten == Mint.money(10, 'EUR') #=> false
67
- ten > Mint.money(9.99, 'USD') #=> true
93
+ ten == 10.dollars #=> true
94
+ ten == Money.from(10, 'EUR') #=> false
95
+ ten > Money.from(9.99, 'USD') #=> true
68
96
 
69
97
  # Zero equality semantics
70
98
  # Any zero amount is treated as equal, regardless of currency
71
- Mint.money(0, 'USD') == Mint.money(0, 'EUR') #=> true
72
- Mint.money(0, 'USD') == 0 #=> true
73
- Mint.money(0, 'USD') == 0.0 #=> true
99
+ Money.from(0, 'USD') == Money.from(0, 'EUR') #=> true
100
+ Money.from(0, 'USD') == 0 #=> true
101
+ Money.from(0, 'USD') == 0.0 #=> true
74
102
 
75
103
  # Non-zero numerics are not equal to Money objects
76
- Mint.money(10, 'USD') == 10 #=> false
104
+ Money.from(10, 'USD') == 10 #=> false
77
105
 
78
- # Format (uses Kernel.format syntax)
79
- price = Mint.money(9.99, 'USD')
80
- loss = Mint.money(-1234.56, 'USD')
106
+ # Ranges and enumeration are supported
107
+ 1.dollar..10.dollars #=> [USD 1.00]..[USD 10.00]
108
+ (1.dollar..3.dollars).step(1.dollar).to_a #=> [[USD 1.00], [USD 2.00], [USD 3.00]]
81
109
 
82
- # Built-in named presets
83
- loss.format(:accounting) #=> "($1,234.56)"
84
- Mint.money(1234.56, 'EUR').format(:european) #=> "1.234,56 €"
85
- price.format(:amount) #=> "9.99"
86
- price.format(:currency) #=> "USD 9.99"
110
+ # Clamping to a range
111
+ price = Money.from(50, 'USD')
112
+ min_price = Money.from(75, 'USD')
87
113
 
88
- # Presets can be overridden with explicit kwargs
89
- Mint.money(1234.56, 'EUR').format(:european, format: '%<amount>f %<currency>s')
90
- #=> "1.234,56 EUR"
114
+ price.clamp(0, 100) #=> [USD 50.00] (returns self, no new object)
115
+ price.clamp(0, 25) #=> [USD 25.00] (clamped to max)
116
+ price.clamp(min_price, 100) #=> [USD 75.00] (clamped to min, Money or Numeric bounds both work)
117
+ ```
91
118
 
92
- # Or use direct format strings
93
- price.format #=> "$9.99",
94
- price.format(format: '%<amount>d') #=> "9",
95
- price.format(format: '%<symbol>s%<amount>f') #=> "$9.99",
96
- price.format(format: '%<symbol>s%<amount>+f') #=> "$+9.99",
97
- (-price).format(format: '%<amount>f') #=> "-9.99",
119
+ ### Formatting
98
120
 
99
- # Format with padding
100
- price_in_euros = Mint.money(12.34, 'EUR')
121
+ ```ruby
122
+ price = Money.from(9.99, 'USD')
123
+
124
+ # Use direct format strings
125
+ price.format #=> "$9.99"
126
+ price.format('%<amount>d') #=> "9"
127
+ price.format('%<symbol>s%<amount>f') #=> "$9.99"
128
+ price.format('%<symbol>s%<amount>+f') #=> "$+9.99"
129
+ (-price).format('%<amount>f') #=> "-9.99"
101
130
 
102
- price.format(format: '--%<amount>7d') #=> "-- 9"
103
- price.format(format: ' %<amount>10f %<currency>s') #=> " 9.99 USD"
104
- (-price).format(format: ' %<amount>10f') #=> " -9.99"
131
+ # Format with padding
132
+ price_in_euros = Money.from(12.34, 'EUR')
105
133
 
106
- price_in_euros.format(format: '%<symbol>2s%<amount>+10f') #=> " € +12.34"
134
+ price.format('--%<amount>7d') #=> "-- 9"
135
+ price.format(' %<amount>10f %<currency>s') #=> " 9.99 USD"
136
+ (-price).format(' %<amount>10f') #=> " -9.99"
137
+ price_in_euros.format('%<symbol>2s%<amount>+10f') #=> " € +12.34"
107
138
 
108
139
  # Integral & fractional parts
109
- price.format(format: '%<integral>d %<fractional>d/100') #=> "9 99/100"
110
- Mint.money(0.99, 'USD').format(format: '%<integral>d dollars and %<fractional>02d cents')
140
+ price.format('%<integral>d %<fractional>d/100') #=> "9 99/100"
141
+ Money.from(0.99, 'USD').format('%<integral>d dollars and %<fractional>02d cents')
111
142
  #=> "0 dollars and 99 cents"
112
143
 
113
144
  # Per-sign Hash format (e.g. accounting parentheses for losses)
114
- loss = Mint.money(-1234.56, 'USD')
115
- loss.format(format: { negative: '(%<symbol>s%<amount>f)' }) #=> "($1,234.56)"
116
- Mint.money(0, 'BRL').format(format: { zero: '--' }) #=> "--"
117
- # All three keys at once:
145
+ loss.format( { negative: '(%<symbol>s%<amount>f)' }) #=> "($1,234.56)"
146
+ Money.from(0, 'BRL').format( { zero: '--' }) #=> "--"
118
147
  fmt = { positive: '%<symbol>s%<amount>f', negative: '(%<symbol>s%<amount>f)', zero: '--' }
119
- Mint.money(1234.56, 'USD').format(format: fmt) #=> "$1,234.56"
148
+ Money.from(1234.56, 'USD').format( fmt) #=> "$1,234.56"
120
149
 
121
- # Json serialization
150
+ # Disambiguated symbol (e.g. "US$" vs "C$" vs "A$")
151
+ Money.from(10, 'USD').format('%<dsymbol>s%<amount>f') #=> "US$10.00"
152
+ Money.from(10, 'CAD').format('%<dsymbol>s%<amount>f') #=> "C$10.00"
153
+ Money.from(10, 'EUR').format('%<dsymbol>s%<amount>f') #=> "€10.00" (falls back to symbol)
122
154
 
123
- price.to_json # => "{\"currency\": \"USD\", \"amount\": \"9.99\"}"
124
-
125
- # Hash conversion
126
-
127
- price.to_hash #=> {currency: "USD", amount: "9.99"}
155
+ # Hash serialization
156
+ price.to_hash #=> {currency: "USD", amount: "9.99"}
157
+ ```
128
158
 
159
+ ### Integral & fractional parts
129
160
 
130
- # Fractional units (inverse of #fractional) - exact integer arithmetic
161
+ ```ruby
162
+ price.integral #=> 9 # whole-unit part
163
+ price.fractional #=> 99 # fractional part (subunits within one unit)
164
+ price.subunits #=> 999 # total amount in smallest unit
165
+ price.to_i #=> 9 # alias of integral
131
166
 
132
- price.subunits #=> 999
133
- Mint::Money.from_subunits(999, 'USD') #=> [USD 9.99]
167
+ Mint::Money.from_subunits(999, 'USD') #=> [USD 9.99]
134
168
  Mint::Money.from_subunits(1234, 'JPY') #=> [JPY 1234] # subunit 0 -> no scaling
135
169
 
136
-
137
170
  # No currency (ISO 4217 XXX)
138
-
139
171
  Mint::Money.no_currency(100) #=> [XXX 100]
140
172
  Mint::Money.no_currency(0) #=> [XXX 0]
141
173
 
142
-
143
174
  # Proportional allocation and split
175
+ ten = 10.dollars
176
+ ten.split(3) #=> [[USD 3.34], [USD 3.33], [USD 3.33]]
177
+ ten.allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
178
+ ```
144
179
 
145
- ten.split(3) #=> [[USD 3.34], [USD 3.33], [USD 3.33]]
146
- ten.allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
180
+ ### Parsing strings
147
181
 
148
- # Clamping to a range
182
+ ```ruby
183
+ Money.parse('$19.99') #=> [USD 19.99]
184
+ Money.parse('19,99 €') #=> [EUR 19.99]
185
+ Money.parse('1.234,56', 'EUR') #=> [EUR 1234.56]
186
+ Money.parse('USD 1,234.56') #=> [USD 1234.56]
187
+ ```
149
188
 
150
- price = Mint.money(50, 'USD')
151
- min_price = Mint.money(75, 'USD')
189
+ Notes:
190
+ - Pass a currency code when the string has no symbol or code.
191
+ - `1,234` means 1234, not 1.234, and `1,23` means 1.23, not 123.
192
+ - `1,234.00` is unambiguous (thousands + decimal).
193
+ - Accounting negatives like `($1.23)` or `(USD 10.00)` are supported — the parser detects parentheses and negates the amount.
194
+ - Ambiguous symbols like `$` resolve by currency priority (currently USD).
195
+ - The parser scans all uppercase words for registered codes, so spurious non-currency words before the real code are correctly ignored: `Money.parse("MAX 10.00 USD")` yields `[USD 10.00]`.
152
196
 
153
- price.clamp(0, 100) #=> [USD 50.00] (returns self, no new object)
154
- price.clamp(0, 25) #=> [USD 25.00] (clamped to max)
155
- price.clamp(min_price, 100) #=> [USD 75.00] (clamped to min)
197
+ ### Currency lookup
156
198
 
157
- # Clamp accepts Money bounds or Numeric amounts
158
- price.clamp(min_price, 100) #=> [USD 75.00]
199
+ ```ruby
200
+ # All registered currencies (150+ ISO 4217 + custom)
201
+ Money::Currency.registered_currencies.size #=> 154
202
+ Money::Currency.registered_currencies.each { |code, c| puts "#{code}: #{c.name}" }
159
203
 
160
- # Ranges and enumeration are supported
204
+ # Built-in ISO 4217 currencies (before custom registrations)
205
+ Money::Currency.world_currencies.size #=> 154
161
206
 
162
- 1.dollar..10.dollars #=> [USD 1.00]..[USD 10.00]
163
- (1.dollar..3.dollars).step(1.dollar).to_a #=> [[USD 1.00], [USD 2.00], [USD 3.00]]
207
+ # By ISO code (direct hash lookup, string only)
208
+ Money::Currency.for_code('USD') #=> #<Currency code="USD" ...>
164
209
 
210
+ # By display symbol (highest-priority currency for ambiguous symbols)
211
+ Money::Currency.for_symbol('$') #=> #<Currency code="USD" ...>
212
+ Money::Currency.for_symbol('R$') #=> #<Currency code="BRL" ...>
213
+ Money::Currency.for_symbol('€') #=> #<Currency code="EUR" ...>
165
214
  ```
166
215
 
167
- ## Parsing strings
216
+ **Polymorphic currency resolution** — `Currency.resolve` also accepts objects that implement `#to_currency` or `#currency_code`:
168
217
 
169
218
  ```ruby
170
- Mint.parse('$19.99') #=> [USD 19.99]
171
- Mint.parse('19,99 €') #=> [EUR 19.99]
172
- Mint.parse('1.234,56', 'EUR') #=> [EUR 1234.56]
173
- Mint.parse('USD 1,234.56') #=> [USD 1234.56]
219
+ class Product
220
+ def currency_code = 'USD'
221
+ end
222
+
223
+ Money::Currency.resolve(Product.new) #=> #<Currency code="USD" ...>
224
+ Money::Currency.resolve!(Product.new) # raises Mint::UnknownCurrency if code is unknown
174
225
  ```
175
226
 
176
- Notes:
177
- - Pass a currency code when the string has no symbol or code.
178
- - `1,234` means 1234, not 1.234 and `1,23` means 1.23, not 123
179
- - `1,234.00` is unambiguous (thousands + decimal).
180
- - Accounting negatives like `($1.23)` or `(USD 10.00)` are supported — the parser detects parentheses and negates the amount.
181
- - Ambiguous symbols like `$` resolve by currency priority (currently USD).
182
- - The parser scans all uppercase words for registered codes, so spurious non-currency words before the real code are correctly ignored: `Mint.parse("MAX 10.00 USD")` yields `[USD 10.00]`.
227
+ `#to_currency` takes precedence when both methods exist. It must return a `Currency` object; `#currency_code` must return a `String`. Wrong types raise `ArgumentError`.
183
228
 
184
- ## Currency lookup
229
+ ### Crypto currencies
230
+
231
+ Minting ships with opt-in definitions for ~25 popular crypto currencies (BTC, ETH, SOL, ...). They are not registered by default — use `register_crypto` to enable them:
185
232
 
186
233
  ```ruby
187
- # By ISO code (direct hash lookup, string only)
188
- Mint::Currency.for_code('USD') #=> #<Currency code="USD" ...>
234
+ Currency.register_crypto('BTC', 'ETH', 'SOL')
189
235
 
190
- # By display symbol (highest-priority currency for ambiguous symbols)
191
- Mint::Currency.for_symbol('$') #=> #<Currency code="USD" ...>
192
- Mint::Currency.for_symbol('R$') #=> #<Currency code="BRL" ...>
193
- Mint::Currency.for_symbol('€') #=> #<Currency code="EUR" ...>
236
+ Money.parse("0.01 BTC") #=> [BTC 0.01000000]
237
+ Mint.money(1, 'ETH') #=> [ETH 1.000000000000000000]
238
+ ```
239
+
240
+ `Currency.crypto_currencies` lists all available definitions without registering:
194
241
 
242
+ ```ruby
243
+ Currency.crypto_currencies.each { |c| puts "#{c.code}: #{c.name}" }
244
+ # BTC: Bitcoin
245
+ # ETH: Ethereum
246
+ # SOL: Solana
247
+ # ...
248
+ ```
249
+
250
+ `register_crypto` raises `KeyError` on duplicate codes and `ArgumentError` on unknown codes. Register all at once:
251
+
252
+ ```ruby
253
+ # Register all at once:
254
+ Currency.register_all_crypto # raises KeyError on any conflict
255
+ ```
256
+
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
+ ### Locale formatting
268
+
269
+ 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:
270
+
271
+ ```ruby
272
+ LOCALE_DATA = {
273
+ 'en' => { decimal: '.', thousand: ',', format: '%<symbol>s%<amount>f' },
274
+ 'pt' => { decimal: ',', thousand: '.', format: '%<symbol>s%<amount>f' },
275
+ 'pt-BR' => { decimal: ',', thousand: '.', format: '%<symbol>s%<amount>f' },
276
+ 'de' => { decimal: ',', thousand: '.', format: '%<amount>f %<currency>s' },
277
+ 'fr' => { decimal: ',', thousand: ' ', format: '%<amount>f %<symbol>s' },
278
+ 'ja' => { decimal: '.', thousand: ',', format: '%<symbol>s%<amount>f' },
279
+ }.freeze
280
+
281
+ Mint.locale_backend = ->(locale) { LOCALE_DATA[locale.to_s] || {} }
282
+
283
+ Mint.money(1234.56, 'USD').format(locale: :en) #=> "$1,234.56"
284
+ Mint.money(9.99, 'BRL').format(locale: 'pt') #=> "R$9,99"
285
+ Mint.money(9.99, 'EUR').format(locale: :de) #=> "9,99 EUR"
286
+ Mint.money(9.99, 'EUR').format(locale: 'fr') #=> "9,99 €"
287
+ Mint.money(9.99, 'USD').format(locale: :ja) #=> "$9.99"
288
+ ```
289
+
290
+ Pass `locale:` as a keyword to `format` / `to_fs`. Accepts both symbols (`:en`, `:'pt-BR'`) and strings (`'pt-BR'`, `'en-US'`) — passed through as-is, matching Rails' `I18n.locale` convention. The backend returns a hash with `:decimal`, `:thousand`, and optionally `:format` (defaults to `'%<symbol>s%<amount>f'`). String and symbol keys are interchangeable. Return `{}` or `nil` for unknown locales — defaults apply.
291
+
292
+ Rails I18n key names (`:separator`, `:delimiter`) are also accepted — no mapping needed:
293
+
294
+ ```ruby
295
+ Mint.locale_backend = ->(locale = nil) {
296
+ I18n.with_locale(locale || I18n.default_locale) do
297
+ I18n.t('number.currency.format', default: {})
298
+ end
299
+ }
300
+ ```
301
+
302
+ Minting names take precedence when both are present (e.g. `{ decimal: '.', separator: ',' }` uses `'.'`).
303
+
304
+ Arity-0 callables (`-> { ... }`) are called without arguments and work unchanged:
305
+
306
+ ```ruby
307
+ Mint.locale_backend = -> { { decimal: ',', thousand: '.' } }
308
+ Mint.money(9.99, 'BRL').format #=> "R$9,99"
195
309
  ```
196
310
 
197
311
  ## API notes
198
312
 
199
313
  **Exact amounts** — Amounts are stored as `Rational` and rounded to the currency subunit.
200
314
 
201
- **Rounding modes** — Wrap operations in `Mint.with_rounding(mode)` to change how amounts are rounded to the subunit:
315
+ **Rounding modes** — Wrap operations in `Money.with_rounding(mode)` to change how amounts are rounded to the subunit:
202
316
 
203
317
  ```ruby
204
- Mint.with_rounding(:half_down) { Mint.money(1.005, 'USD') } #=> [USD 1.00]
205
- Mint.with_rounding(:ceil) { Mint.money(1.001, 'USD') } #=> [USD 1.01]
206
- Mint.with_rounding(:floor) { Mint.parse('1.009', 'USD') } #=> [USD 1.00]
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]
207
321
  ```
208
322
 
209
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.
210
324
 
211
- > **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 `Mint.with_rounding` loads the rounding module and patches `Currency#normalize_amount`, adding ~10–35ns per money creation or mutation. If your application never uses custom rounding modes (the common case), there is **no performance cost**.
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**.
212
326
 
213
327
  **Division** — `money / 5` returns new `Money`; `money / other_money` returns a numeric ratio, not money.
214
328
 
215
- **Zero equality** — Any zero amount is considered equal across currencies and to numeric zero (`Mint.money(0, 'USD') == Mint.money(0, 'EUR')` is intentionally `true`). Non-zero amounts must match currency and value.
329
+ **Zero equality** — Any zero amount is considered equal across currencies and to numeric zero (`Money.from(0, 'USD') == Money.from(0, 'EUR')` is intentionally `true`). Non-zero amounts must match currency and value.
216
330
 
217
- **Zero helper** — `Currency.zero('USD')` returns a frozen zero-Money, useful as a default value for discounts, totals, or counters.
218
331
 
219
- **Registered currencies** — `Currency.register(code:, subunit:, symbol:, priority:)` adds custom currencies. Only registered codes and symbols are recognized by the parser or searches. Nonetheless, you don't need to register a currency to use it with most of the features.
332
+
333
+ **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.
220
334
 
221
335
  **Built-in currencies** — 150+ ISO-4217 world currencies ship in `lib/minting/data/currencies.yaml` and load when the registry is first accessed.
222
336
 
@@ -227,7 +341,7 @@ By default, `require "minting"` exposes `Mint::Money` as the top-level `Money` c
227
341
  ```ruby
228
342
  require "minting"
229
343
 
230
- price = Money.from(10, "USD") # equivalent to Mint::Money.from
344
+ price = Money.from(10, "USD") # equivalent to Mint::Money.from
231
345
  tax = Money.from(2.50, "USD")
232
346
  ```
233
347
 
@@ -235,26 +349,35 @@ tax = Money.from(2.50, "USD")
235
349
 
236
350
  ```ruby
237
351
  require "minting"
238
- require "minting/mint/aliases" # opt-in top-level Currency
352
+ require "minting/aliases" # opt-in top-level Currency
239
353
 
240
354
  cur = Currency.new(code: "EUR", symbol: "€", subunit: 2, priority: 0)
241
355
  ```
242
356
 
243
- For Rails applications, you can enable the top-level `Currency` constant in an initializer:
357
+ For Rails applications, enable it in an initializer:
244
358
 
245
359
  ```ruby
246
360
  # config/initializers/minting.rb
247
- require "minting/mint/aliases"
361
+ require "minting/aliases"
248
362
  ```
249
363
 
250
- If another `Money` is already defined when `require "minting"` runs (e.g. the `money` gem was loaded first), Minting warns and skips the auto-bind — use `Mint::Money` in that case. The same applies to `Currency` via `minting/mint/aliases`.
364
+ If another `Money` is already defined when `require "minting"` runs (e.g. the `money` gem was loaded first), Minting warns and skips the auto-bind — use `Mint::Money` in that case. The same applies to `Currency` via `minting/aliases`.
251
365
 
252
366
  **Good fit:** Application code, especially Rails apps.
253
367
  **Not recommended:** Reusable gems/libraries — stick to `Mint::Money` to avoid conflicts.
254
368
 
369
+ ## Rails integration
370
+
371
+ Minting itself has no Rails dependency. For `ActiveRecord` type casting, validators, and form helpers, pair it with the companion gem:
372
+
373
+ - **[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
+
255
375
  ## Roadmap
256
376
 
257
- - Exchange-rate conversion infrastructure
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
258
381
 
259
382
  ## License
260
383
 
data/Rakefile CHANGED
@@ -2,10 +2,9 @@ require 'bundler/audit/task'
2
2
  require 'bundler/gem_tasks'
3
3
  require 'rake/testtask'
4
4
  require 'rubocop/rake_task'
5
- require 'rubycritic/rake_task'
6
5
  require 'yard'
7
6
 
8
- CLOBBER.include %w[doc/css doc/js doc/Mint doc/*.html tmp .yardoc]
7
+ CLOBBER.include %w[doc/css doc/js doc/Mint doc/*.html tmp .yardoc pkg tmp/*]
9
8
 
10
9
  Rake::TestTask.new(:test) do |t|
11
10
  t.libs << 'test'
@@ -15,46 +14,42 @@ Rake::TestTask.new(:test) do |t|
15
14
  end
16
15
 
17
16
  Rake::TestTask.new('bench:all') do |t|
18
- t.libs = %w[lib test]
19
- t.pattern = 'test/performance/{core,memory,regression,competitive/money}/*_benchmark.rb'
17
+ t.libs = %w[lib bench]
18
+ t.pattern = 'bench/{core,memory,regression}/*_benchmark.rb'
20
19
  end
21
20
 
22
21
  Rake::TestTask.new('bench:core') do |t|
23
- t.libs = %w[lib test]
24
- t.pattern = 'test/performance/core/parse_benchmark.rb'
22
+ t.libs = %w[lib bench]
23
+ t.pattern = 'bench/core/parse_benchmark.rb'
25
24
  end
26
25
 
27
26
  Rake::TestTask.new('bench:memory') do |t|
28
- t.libs = %w[lib test]
29
- t.pattern = 'test/performance/memory/*_benchmark.rb'
27
+ t.libs = %w[lib bench]
28
+ t.pattern = 'bench/memory/*_benchmark.rb'
30
29
  end
31
30
 
32
31
  Rake::TestTask.new('bench:regression') do |t|
33
- t.libs = %w[lib test]
34
- t.pattern = 'test/performance/regression/*_benchmark.rb'
32
+ t.libs = %w[lib bench]
33
+ t.pattern = 'bench/regression/*_benchmark.rb'
35
34
  end
36
35
 
37
36
  desc 'Run competitive benchmarks (Money gem)'
38
- task 'bench:competitive' do
39
- sh 'bundle exec ruby -Ilib -Itest -e "Dir[File.join(__dir__, \"test/performance/competitive/money/**/*_benchmark.rb\")].each { |f| require f }"'
37
+ task 'bench:against:money' do
38
+ dir = 'bench/competitive/money'
39
+ sh "BUNDLE_GEMFILE=#{dir}/Gemfile bundle exec ruby -Ilib -I#{dir} -Ibench -e \"Dir[File.join(__dir__, '#{dir}/**/*_benchmark.rb')].each { |f| require f }\""
40
40
  end
41
41
 
42
42
  desc 'Run competitive benchmarks (Shopify Money)'
43
- task 'bench:competitive:shopify' do
44
- sh 'BUNDLE_WITHOUT=money_bench bundle exec ruby -Ilib -Itest -e "Dir[File.join(__dir__, \"test/performance/competitive/shopify/**/*_benchmark.rb\")].each { |f| require f }"'
45
- end
46
-
47
- desc 'Run all competitive benchmarks (both Money and Shopify)'
48
- task 'bench:competitive:all' do
49
- sh 'bundle exec ruby -Ilib -Itest -e "Dir[File.join(__dir__, \"test/performance/competitive/money/**/*_benchmark.rb\")].each { |f| require f }"'
50
- sh 'BUNDLE_WITHOUT=money_bench bundle exec ruby -Ilib -Itest -e "Dir[File.join(__dir__, \"test/performance/competitive/shopify/**/*_benchmark.rb\")].each { |f| require f }"'
43
+ task 'bench:against:shopify' do
44
+ dir = 'bench/competitive/shopify'
45
+ sh "BUNDLE_GEMFILE=#{dir}/Gemfile bundle exec ruby -Ilib -I#{dir} -Ibench -e \"Dir[File.join(__dir__, '#{dir}/**/*_benchmark.rb')].each { |f| require f }\""
51
46
  end
52
47
 
53
48
  desc 'Run core benchmarks and update the baseline'
54
49
  task 'bench:baseline' do
55
50
  platform = RUBY_PLATFORM
56
- baseline = "test/performance/check/results/baseline-#{platform}.json"
57
- sh "ruby test/performance/check/runner.rb #{baseline}"
51
+ baseline = "bench/check/results/baseline-#{platform}.json"
52
+ sh "bundle exec ruby bench/check/runner.rb #{baseline}"
58
53
  puts "Baseline updated for #{platform}."
59
54
  end
60
55
 
@@ -69,10 +64,6 @@ RuboCop::RakeTask.new(:cop) do |task|
69
64
  task.patterns = ['lib']
70
65
  end
71
66
 
72
- RubyCritic::RakeTask.new do |task|
73
- task.name = 'critic'
74
- end
75
-
76
67
  YARD::Rake::YardocTask.new do |t|
77
68
  t.files = ['lib/**/*.rb']
78
69
  t.stats_options = ['--list-undoc']
data/bin/bench_check CHANGED
@@ -5,7 +5,7 @@ require 'json'
5
5
  require 'tempfile'
6
6
 
7
7
  platform = RUBY_PLATFORM
8
- results_dir = File.expand_path('../test/performance/check/results', __dir__)
8
+ results_dir = File.expand_path('../bench/check/results', __dir__)
9
9
  baseline_path = File.join(results_dir, "baseline-#{platform}.json")
10
10
  threshold = (ARGV[0] || 0.80).to_f
11
11
 
@@ -17,7 +17,7 @@ end
17
17
  baseline = JSON.parse(File.read(baseline_path))
18
18
  current_json = Tempfile.new(%w[bench_current .json])
19
19
 
20
- unless system("bundle exec ruby test/performance/check/runner.rb #{current_json.path}")
20
+ unless system("bundle exec ruby bench/check/runner.rb #{current_json.path}")
21
21
  puts 'Benchmark runner failed.'
22
22
  exit 1
23
23
  end
@@ -127,7 +127,7 @@ This is odd: the param type is a `Currency` in the docs, but you call `other.cur
127
127
  other_currency =
128
128
  case other
129
129
  when Mint::Money then other.currency
130
- when Mint::Currency then other
130
+ when Money::Currency then other
131
131
  else
132
132
  Currency.resolve!(other)
133
133
  end