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.
- checksums.yaml +4 -4
- data/README.md +244 -121
- data/Rakefile +17 -26
- data/bin/bench_check +2 -2
- data/doc/agents/api_review-2026-06-15.md +1 -1
- data/doc/agents/copilot-instructions.md +5 -5
- data/doc/agents/expired/copilot-instructions.md +2 -2
- data/doc/agents/expired/gemini_gem_evaluation.md +2 -2
- data/lib/minting/aliases.rb +22 -0
- data/lib/minting/currency/currency.rb +87 -12
- data/lib/minting/data/crypto-currencies.yaml +126 -0
- data/lib/minting/data/world-currencies.yaml +75 -23
- data/lib/minting/mint/i18n.rb +79 -29
- data/lib/minting/mint/mint.rb +1 -26
- data/lib/minting/mint/registry/crypto.rb +59 -0
- data/lib/minting/mint/registry/registration.rb +1 -2
- data/lib/minting/mint/registry/symbols.rb +37 -30
- data/lib/minting/mint/rounding.rb +10 -7
- data/lib/minting/mint.rb +1 -2
- data/lib/minting/money/allocation/allocation.rb +2 -2
- data/lib/minting/money/allocation/split.rb +1 -1
- data/lib/minting/money/arithmetics/operators.rb +10 -13
- data/lib/minting/money/clamp.rb +6 -6
- data/lib/minting/money/coercion.rb +1 -1
- data/lib/minting/money/comparable.rb +3 -3
- data/lib/minting/money/constructors.rb +3 -42
- data/lib/minting/money/conversion.rb +23 -18
- data/lib/minting/money/format/format.rb +100 -0
- data/lib/minting/money/format/formatter.rb +102 -0
- data/lib/minting/money/format/to_s.rb +34 -99
- data/lib/minting/money/format/validator.rb +34 -0
- data/lib/minting/money/money.rb +25 -13
- data/lib/minting/money/parse.rb +127 -0
- data/lib/minting/money/rounding.rb +27 -0
- data/lib/minting/version.rb +1 -1
- data/lib/minting.rb +17 -8
- metadata +9 -28
- data/doc/Mint/Currency.html +0 -1943
- data/doc/Mint/CurrencyRegistry.html +0 -511
- data/doc/Mint/Money.html +0 -4607
- data/doc/Mint/RangeStepPatch.html +0 -277
- data/doc/Mint/Registry.html +0 -863
- data/doc/Mint/Rounding.html +0 -506
- data/doc/Mint/UnknownCurrency.html +0 -136
- data/doc/Mint.html +0 -1004
- data/doc/Minting.html +0 -142
- data/doc/_index.html +0 -180
- data/doc/class_list.html +0 -54
- data/doc/css/common.css +0 -1
- data/doc/css/full_list.css +0 -206
- data/doc/css/style.css +0 -1089
- data/doc/file.README.html +0 -277
- data/doc/file_list.html +0 -59
- data/doc/frames.html +0 -22
- data/doc/index.html +0 -277
- data/doc/js/app.js +0 -801
- data/doc/js/full_list.js +0 -334
- data/doc/js/jquery.js +0 -4
- data/doc/method_list.html +0 -686
- data/doc/top-level-namespace.html +0 -151
- data/lib/minting/mint/aliases.rb +0 -16
- data/lib/minting/mint/parser/parser.rb +0 -97
- data/lib/minting/mint/parser/separators.rb +0 -41
- data/lib/minting/money/format/formatting.rb +0 -97
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 20bb9d0f4d0437f84ab790d3fdbd27650bee49d0b9d1231b3ba19888987583b4
|
|
4
|
+
data.tar.gz: 8787d2359db2b7c75a2054fdcc6d56525e819f25b4d1cc746d2d6bcb01fd03ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://badge.fury.io/rb/minting)
|
|
6
2
|
[](https://github.com/gferraz/minting/actions/workflows/ci.yml)
|
|
7
3
|
[](https://github.com/gferraz/minting)
|
|
8
4
|
[](https://www.rubydoc.info/gems/minting/frames)
|
|
9
5
|
|
|
10
|
-
|
|
6
|
+
# Minting
|
|
11
7
|
|
|
12
|
-
|
|
13
|
-
require 'minting'
|
|
8
|
+
**Fast, precise, and developer-friendly money handling for Ruby.**
|
|
14
9
|
|
|
15
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
Intuitive interface, descriptive error messages, and sensible defaults. Works the way you expect.
|
|
43
|
+
## Why Minting
|
|
31
44
|
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
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
|
-
|
|
58
|
-
ten = Mint.money(10, 'USD') #=> [USD 10.00]
|
|
86
|
+
ten = Money.from(10, 'USD') #=> [USD 10.00]
|
|
59
87
|
|
|
60
|
-
1.dollar ==
|
|
61
|
-
ten = 10.dollars
|
|
62
|
-
4.to_money('USD')
|
|
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
|
|
66
|
-
ten ==
|
|
67
|
-
ten >
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
104
|
+
Money.from(10, 'USD') == 10 #=> false
|
|
77
105
|
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
#=>
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
(-price).format(format: ' %<amount>10f') #=> " -9.99"
|
|
131
|
+
# Format with padding
|
|
132
|
+
price_in_euros = Money.from(12.34, 'EUR')
|
|
105
133
|
|
|
106
|
-
|
|
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(
|
|
110
|
-
|
|
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
|
|
115
|
-
|
|
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
|
-
|
|
148
|
+
Money.from(1234.56, 'USD').format( fmt) #=> "$1,234.56"
|
|
120
149
|
|
|
121
|
-
#
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
ten.allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
|
|
180
|
+
### Parsing strings
|
|
147
181
|
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
#
|
|
204
|
+
# Built-in ISO 4217 currencies (before custom registrations)
|
|
205
|
+
Money::Currency.world_currencies.size #=> 154
|
|
161
206
|
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
216
|
+
**Polymorphic currency resolution** — `Currency.resolve` also accepts objects that implement `#to_currency` or `#currency_code`:
|
|
168
217
|
|
|
169
218
|
```ruby
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
Mint::Currency.for_code('USD') #=> #<Currency code="USD" ...>
|
|
234
|
+
Currency.register_crypto('BTC', 'ETH', 'SOL')
|
|
189
235
|
|
|
190
|
-
|
|
191
|
-
Mint
|
|
192
|
-
|
|
193
|
-
|
|
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 `
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 `
|
|
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 (`
|
|
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
|
-
|
|
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")
|
|
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/
|
|
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,
|
|
357
|
+
For Rails applications, enable it in an initializer:
|
|
244
358
|
|
|
245
359
|
```ruby
|
|
246
360
|
# config/initializers/minting.rb
|
|
247
|
-
require "minting/
|
|
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/
|
|
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
|
-
|
|
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
|
|
19
|
-
t.pattern = '
|
|
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
|
|
24
|
-
t.pattern = '
|
|
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
|
|
29
|
-
t.pattern = '
|
|
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
|
|
34
|
-
t.pattern = '
|
|
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:
|
|
39
|
-
|
|
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:
|
|
44
|
-
|
|
45
|
-
|
|
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 = "
|
|
57
|
-
sh "ruby
|
|
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('../
|
|
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
|
|
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
|
|
130
|
+
when Money::Currency then other
|
|
131
131
|
else
|
|
132
132
|
Currency.resolve!(other)
|
|
133
133
|
end
|