minting 2.2.0 → 2.3.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 +34 -7
- data/doc/agents/AGENTS.md +7 -1
- data/lib/minting/currency/currency.rb +1 -7
- data/lib/minting/currency/registry.rb +6 -2
- data/lib/minting/currency/rounding.rb +14 -1
- data/lib/minting/mint/dsl/string.rb +1 -1
- data/lib/minting/mint/registry/crypto.rb +4 -2
- data/lib/minting/mint/registry/registration.rb +6 -2
- data/lib/minting/money/allocation/allocation.rb +6 -1
- data/lib/minting/money/conversion.rb +3 -0
- data/lib/minting/money/format/format.rb +10 -1
- data/lib/minting/money/format/formatter.rb +23 -17
- data/lib/minting/money/money.rb +2 -1
- data/lib/minting/money/parse/separator_parser.rb +60 -0
- data/lib/minting/money/parse.rb +45 -10
- data/lib/minting/money/rounding.rb +4 -5
- data/lib/minting/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3af2c270bbab98102901e0f9c1aefb22966d916805cd04a7eab8e867fe9f59be
|
|
4
|
+
data.tar.gz: 109d647aceff1385f35135c91b1728237965e549de4744717ff28956f4e33659
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ac0ee4402ad94a39aa256fdcefaa24333f89333de1ee216b7ca698e493fb2410d771115c8a7e694d7acec8228bb5301db8ba6643e1e277bf87aa2aaf132b65d
|
|
7
|
+
data.tar.gz: 41e8ff87efe32f54185a96fc7e9781c02304a9a24b3090bb50bd5f8532488ab581c08ccbf2be47d469d18d63fed9fda2cf74776bf066dc8aac48aca982bb90e7
|
data/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
> **Status:** Minting 2.
|
|
15
|
+
> **Status:** Minting 2.3 is released. The core API (`Money`, `Currency`, formatting, parsing) is stable.
|
|
16
16
|
|
|
17
17
|
```ruby
|
|
18
18
|
price = Money.from(19.99, 'USD') #=> [USD 19.99]
|
|
@@ -119,7 +119,7 @@ What it's become along the way:
|
|
|
119
119
|
|
|
120
120
|
- **Exact by construction** — amounts are `Rational` internally, rounded to the currency's subunit only when needed. No silent precision loss from repeated arithmetic.
|
|
121
121
|
- **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.
|
|
122
|
-
- **Formatting that doesn't fight you** — `Kernel.format`-style templates,
|
|
122
|
+
- **Formatting that doesn't fight you** — `Kernel.format`-style templates, per-sign formats (parentheses for negatives), explicit sign and magnitude placeholders, and a pluggable locale hook.
|
|
123
123
|
- **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.
|
|
124
124
|
- **Measured, not assumed, performance** — see the [Performance Guide](bench/BENCHMARKS.md) for actual benchmarks rather than claims.
|
|
125
125
|
- **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.
|
|
@@ -207,6 +207,7 @@ price.format('%<amount>d') #=> "9"
|
|
|
207
207
|
price.format('%<symbol>s%<amount>f') #=> "$9.99"
|
|
208
208
|
price.format('%<symbol>s%<amount>+f') #=> "$+9.99"
|
|
209
209
|
(-price).format('%<amount>f') #=> "-9.99"
|
|
210
|
+
(-price).format('%<sign>s%<symbol>s%<magnitude>f') #=> "-$9.99"
|
|
210
211
|
|
|
211
212
|
# Format with padding
|
|
212
213
|
price_in_euros = Money.from(12.34, 'EUR')
|
|
@@ -220,6 +221,8 @@ price_in_euros.format('%<symbol>2s%<amount>+10f') #=> " € +12.34"
|
|
|
220
221
|
price.format('%<integral>d %<fractional>d/100') #=> "9 99/100"
|
|
221
222
|
Money.from(0.99, 'USD').format('%<integral>d dollars and %<fractional>02d cents')
|
|
222
223
|
#=> "0 dollars and 99 cents"
|
|
224
|
+
(-Money.from(0.99, 'USD')).format('%<sign>s%<integral>d.%<fractional>02d')
|
|
225
|
+
#=> "-0.99"
|
|
223
226
|
|
|
224
227
|
# Per-sign Hash format (e.g. accounting parentheses for losses)
|
|
225
228
|
loss = Money.from(-1234.56, 'USD')
|
|
@@ -269,7 +272,7 @@ Money.parse('USD 1,234.56') #=> [USD 1234.56]
|
|
|
269
272
|
|
|
270
273
|
Notes:
|
|
271
274
|
- Pass a currency code when the string has no symbol or code.
|
|
272
|
-
- The optional
|
|
275
|
+
- The optional `default_currency:` argument is a default, not an override: a code or symbol embedded in the string takes precedence (`Money.parse('10 EUR', default_currency: 'USD')` returns EUR). The positional currency argument is deprecated.
|
|
273
276
|
- `1,234` means 1234, not 1.234, and `1,23` means 1.23, not 123.
|
|
274
277
|
- `1,234.00` is unambiguous (thousands + decimal).
|
|
275
278
|
- Parsing is separator-positional rather than locale-aware. For currencies
|
|
@@ -277,6 +280,16 @@ Notes:
|
|
|
277
280
|
as a thousands separator: `Money.parse('KWD 861,949')` means 861949 KWD.
|
|
278
281
|
Use a period decimal (`KWD 861.949`) or both separators (`KWD 1.234,567`)
|
|
279
282
|
when the intended value has three fractional digits.
|
|
283
|
+
- Pass the input source's decimal separator explicitly when a single separator
|
|
284
|
+
followed by three digits is ambiguous: `Money.parse('123,456', 'EUR',
|
|
285
|
+
decimal: ',')` means `123.456`, while `decimal: '.'` means `123456`. The
|
|
286
|
+
default currency can be passed positionally or as `default_currency: 'EUR'`. When
|
|
287
|
+
the input is an integer with grouping, pass `thousand:` explicitly instead:
|
|
288
|
+
`Money.parse('123,456', 'USD', thousand: ',')` means `123456`. When both
|
|
289
|
+
options are supplied, they define the roles independently, so a decimal
|
|
290
|
+
separator that is absent from the input is also valid:
|
|
291
|
+
`Money.parse('123,456', 'USD', decimal: ':', thousand: ',')` means `123456`.
|
|
292
|
+
When both options are omitted, the positional rules above remain in effect.
|
|
280
293
|
- Accounting negatives like `($1.23)` or `(USD 10.00)` are supported — the parser detects parentheses and negates the amount.
|
|
281
294
|
- Ambiguous symbols like `$` resolve by currency priority (currently USD).
|
|
282
295
|
- 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]`.
|
|
@@ -389,6 +402,18 @@ Money.from(9.99, 'BRL').format #=> "R$9,99"
|
|
|
389
402
|
|
|
390
403
|
**Exact amounts** — Amounts are stored as `Rational` and rounded to the currency subunit.
|
|
391
404
|
|
|
405
|
+
**Sign and magnitude placeholders** — `%<amount>` retains its sign.
|
|
406
|
+
`%<magnitude>` always renders the absolute value, while `%<sign>` renders `+`
|
|
407
|
+
for positive values, `-` for negative values, and an empty string for zero.
|
|
408
|
+
Use a width of `1` to reserve the sign column for zero values:
|
|
409
|
+
|
|
410
|
+
```ruby
|
|
411
|
+
format = '%<sign>1s%<symbol>s%<magnitude>f'
|
|
412
|
+
Money.from(12.34, 'BRL').format(format, decimal: ',') #=> "+R$12,34"
|
|
413
|
+
Money.from(-12.34, 'BRL').format(format, decimal: ',') #=> "-R$12,34"
|
|
414
|
+
Money.from(0, 'BRL').format(format, decimal: ',') #=> " R$0,00"
|
|
415
|
+
```
|
|
416
|
+
|
|
392
417
|
**Rounding modes** — Wrap operations in `Money.with_rounding(mode)` to change how amounts are rounded to the subunit:
|
|
393
418
|
|
|
394
419
|
```ruby
|
|
@@ -444,17 +469,19 @@ price.clamp(Money.from(8, 'USD')..Money.from(12, 'USD')) #=> [USD 10.00]
|
|
|
444
469
|
**Allocation** — `split(n)` requires a positive integer. `allocate(ratios)`
|
|
445
470
|
requires a non-empty list whose total is non-zero. Both preserve the original
|
|
446
471
|
amount after subunit rounding; any leftover smallest units go to the first
|
|
447
|
-
slots. Ratios may be negative, but use them only
|
|
448
|
-
intentional
|
|
472
|
+
slots. Ratios may be negative and produce signed allocations, but use them only
|
|
473
|
+
when that distribution is intentional:
|
|
449
474
|
|
|
450
475
|
```ruby
|
|
451
476
|
Money.from(10, 'USD').split(3) #=> [[USD 3.34], [USD 3.33], [USD 3.33]]
|
|
452
477
|
Money.from(10, 'USD').allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
|
|
478
|
+
Money.from(10, 'USD').allocate([-1, 2]) #=> [[USD -10.00], [USD 20.00]]
|
|
453
479
|
```
|
|
454
480
|
|
|
455
481
|
**Conversions and serialization** — `to_r` is exact, `to_d` returns a
|
|
456
482
|
BigDecimal, and `to_f` can lose precision. `to_hash` and `from_hash` provide a
|
|
457
|
-
string-safe round trip
|
|
483
|
+
string-safe round trip. JSON and Rails `as_json` integration belong to the
|
|
484
|
+
`money_attribute` companion gem, not the core Minting gem:
|
|
458
485
|
|
|
459
486
|
```ruby
|
|
460
487
|
money = Money.from(9.99, 'USD')
|
|
@@ -471,7 +498,7 @@ older supported Rubies.
|
|
|
471
498
|
|
|
472
499
|
|
|
473
500
|
|
|
474
|
-
**Registered currencies** — `Money::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.
|
|
501
|
+
**Registered currencies** — `Money::Currency.register(code:, subunit:, symbol:, priority:, country:, name:, disambiguate_symbol:)` adds custom currencies. `country`, `name`, and `disambiguate_symbol` are optional metadata fields; `disambiguate_symbol` is also used by `%<dsymbol>s` and symbol parsing. 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.
|
|
475
502
|
|
|
476
503
|
**Built-in currencies** — 150+ ISO-4217 world currencies ship in `lib/minting/data/world-currencies.yaml` and are preloaded at gem initialization.
|
|
477
504
|
|
data/doc/agents/AGENTS.md
CHANGED
|
@@ -191,7 +191,10 @@ formatting fast path; call `format` or `to_fs` for custom output.
|
|
|
191
191
|
|
|
192
192
|
Format strings use `Kernel.format` named-reference syntax:
|
|
193
193
|
`%<symbol>s`, `%<amount>f`, `%<amount>d`, `%<currency>s`, `%<integral>d`,
|
|
194
|
-
`%<fractional>d`.
|
|
194
|
+
`%<fractional>d`. `%<amount>` is signed, `%<magnitude>` and `%<fractional>` are
|
|
195
|
+
non-negative, and `%<sign>` exposes `+`, `-`, or an empty string for zero. Use
|
|
196
|
+
`%<sign>` for explicit sign placement, especially for amounts between -1 and 1.
|
|
197
|
+
The `%<amount>f` and `%<magnitude>f` specifiers have the currency's subunit
|
|
195
198
|
precision **injected at runtime** (e.g. `%<amount>f` → `%<amount>.2f` for
|
|
196
199
|
USD) by a gsub in `format/formatter.rb`. For zero-subunit currencies (JPY),
|
|
197
200
|
`%<fractional>d` receives zero.
|
|
@@ -204,6 +207,9 @@ Compiled formatters are retained in a thread-safe, copy-on-write cache capped
|
|
|
204
207
|
at 256 configurations. Once full, new configurations are compiled for the
|
|
205
208
|
call but not retained. Do not assume `Formatter.cache` is mutable.
|
|
206
209
|
|
|
210
|
+
Minting's core serialization API is `to_hash`/`from_hash`; JSON and Rails
|
|
211
|
+
`as_json` integration are provided by the `money_attribute` companion gem.
|
|
212
|
+
|
|
207
213
|
`Mint.locale_backend=` (a callable or Hash returning
|
|
208
214
|
`{ decimal:, thousand:, format: }`) supplies defaults when the corresponding
|
|
209
215
|
kwarg is nil. This is how `attribute-money` wires I18n. See
|
|
@@ -89,13 +89,7 @@ module Mint
|
|
|
89
89
|
# usd.normalize_amount("5.25") #=> (21/4)
|
|
90
90
|
#
|
|
91
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
|
|
92
|
+
def normalize_amount(amount) = amount.to_r.round(subunit)
|
|
99
93
|
|
|
100
94
|
# Returns the cached frozen zero-Money for this currency.
|
|
101
95
|
#
|
|
@@ -32,11 +32,15 @@ module Mint
|
|
|
32
32
|
# @param subunit [Integer] the decimal subunit precision, defaults to 0
|
|
33
33
|
# @param symbol [String] the display symbol
|
|
34
34
|
# @param priority [Integer] parser precedence priority
|
|
35
|
+
# @param country [String, nil] associated country code
|
|
36
|
+
# @param name [String, nil] currency name
|
|
37
|
+
# @param disambiguate_symbol [String, nil] symbol variant for disambiguation
|
|
35
38
|
# @return [Currency] the newly registered Currency instance
|
|
36
39
|
# @raise [ArgumentError] if the code contains invalid characters
|
|
37
40
|
# @raise [KeyError] if the currency code is already registered
|
|
38
|
-
def Currency.register(code:, subunit: 0, symbol: '', priority: 0
|
|
39
|
-
|
|
41
|
+
def Currency.register(code:, subunit: 0, symbol: '', priority: 0, country: nil, name: nil,
|
|
42
|
+
disambiguate_symbol: nil)
|
|
43
|
+
Registry.register(code:, subunit:, symbol:, priority:, country:, name:, disambiguate_symbol:)
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
# Registers all built-in crypto currencies at once.
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
module Mint
|
|
5
5
|
# :nodoc:
|
|
6
6
|
class Currency
|
|
7
|
+
# Replaces the default normalization method after custom rounding is first
|
|
8
|
+
# requested, keeping the default path free of dispatch and thread checks.
|
|
9
|
+
module CustomRounding
|
|
10
|
+
def normalize_amount(amount)
|
|
11
|
+
amount.to_r.round(subunit, half: Thread.current[Currency::ROUNDING_THREAD_KEY] || :up)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
7
15
|
# @api private
|
|
8
16
|
VALID_ROUNDING_MODES = %i[up down even].freeze
|
|
9
17
|
|
|
@@ -17,7 +25,12 @@ module Mint
|
|
|
17
25
|
# Activates the custom rounding dispatch path in {#normalize_amount}.
|
|
18
26
|
# Once called, this cannot be reversed for the lifetime of the process.
|
|
19
27
|
# @api private
|
|
20
|
-
def self.activate_custom_rounding!
|
|
28
|
+
def self.activate_custom_rounding!
|
|
29
|
+
return if @custom_rounding_active
|
|
30
|
+
|
|
31
|
+
@custom_rounding_active = true
|
|
32
|
+
prepend(CustomRounding)
|
|
33
|
+
end
|
|
21
34
|
|
|
22
35
|
# Returns the currently active rounding mode, falling back to +:up+.
|
|
23
36
|
# @api private
|
|
@@ -6,5 +6,5 @@ class String
|
|
|
6
6
|
#
|
|
7
7
|
# @param currency [String, Currency] default currency when self has no currency marker
|
|
8
8
|
# @return [Money]
|
|
9
|
-
def to_money(currency = nil) = Mint::Money.parse(self, currency)
|
|
9
|
+
def to_money(currency = nil) = Mint::Money.parse(self, default_currency: currency)
|
|
10
10
|
end
|
|
@@ -42,7 +42,8 @@ module Mint
|
|
|
42
42
|
|
|
43
43
|
codes.map do |code|
|
|
44
44
|
c = entries[index[code]]
|
|
45
|
-
Currency.register(code:, subunit: c.subunit, symbol: c.symbol, priority: c.priority
|
|
45
|
+
Currency.register(code:, subunit: c.subunit, symbol: c.symbol, priority: c.priority,
|
|
46
|
+
country: c.country, name: c.name, disambiguate_symbol: c.disambiguate_symbol)
|
|
46
47
|
end
|
|
47
48
|
end
|
|
48
49
|
|
|
@@ -54,7 +55,8 @@ module Mint
|
|
|
54
55
|
# @return [Array<Currency>] the newly registered Currency objects
|
|
55
56
|
def self.register_all_crypto
|
|
56
57
|
crypto_currencies.map do |c|
|
|
57
|
-
Currency.register(code: c.code, subunit: c.subunit, symbol: c.symbol, priority: c.priority
|
|
58
|
+
Currency.register(code: c.code, subunit: c.subunit, symbol: c.symbol, priority: c.priority,
|
|
59
|
+
country: c.country, name: c.name, disambiguate_symbol: c.disambiguate_symbol)
|
|
58
60
|
end
|
|
59
61
|
end
|
|
60
62
|
end
|
|
@@ -9,10 +9,14 @@ module Mint
|
|
|
9
9
|
# @param subunit [Integer] the decimal subunit precision, defaults to 0
|
|
10
10
|
# @param symbol [String] the display symbol
|
|
11
11
|
# @param priority [Integer] parser precedence priority
|
|
12
|
+
# @param country [String, nil] associated country code
|
|
13
|
+
# @param name [String, nil] currency name
|
|
14
|
+
# @param disambiguate_symbol [String, nil] symbol variant for disambiguation
|
|
12
15
|
# @return [Currency] the newly registered Currency instance
|
|
13
16
|
# @raise [ArgumentError] if the code contains invalid characters
|
|
14
17
|
# @raise [KeyError] if the currency code is already registered
|
|
15
|
-
def self.register(code:, subunit: 0, symbol: '', priority: 0
|
|
18
|
+
def self.register(code:, subunit: 0, symbol: '', priority: 0, country: nil, name: nil,
|
|
19
|
+
disambiguate_symbol: nil)
|
|
16
20
|
raise ArgumentError, 'Currency code must be String' unless code.is_a? String
|
|
17
21
|
unless code.match?(/^[A-Z_]+$/)
|
|
18
22
|
raise ArgumentError,
|
|
@@ -22,7 +26,7 @@ module Mint
|
|
|
22
26
|
MUTEX.synchronize do
|
|
23
27
|
raise KeyError, "Currency: #{code} already registered" if currencies[code]
|
|
24
28
|
|
|
25
|
-
currency = Currency.new(code:, subunit:, symbol:, priority:)
|
|
29
|
+
currency = Currency.new(code:, subunit:, symbol:, priority:, country:, name:, disambiguate_symbol:)
|
|
26
30
|
@currencies = @currencies.merge(code => currency).freeze
|
|
27
31
|
@symbols_list = nil
|
|
28
32
|
currency
|
|
@@ -5,13 +5,18 @@ module Mint
|
|
|
5
5
|
class Money
|
|
6
6
|
# Proportionally allocates the monetary amount among a list of ratios.
|
|
7
7
|
# Disperses any subunit rounding amounts across the initial slots
|
|
8
|
-
# @param proportions [Array<Numeric>] a list of numeric proportions/ratios to allocate by
|
|
8
|
+
# @param proportions [Array<Numeric>] a list of numeric proportions/ratios to allocate by.
|
|
9
|
+
# Negative ratios produce signed allocations and should be used only when
|
|
10
|
+
# that distribution is intentional.
|
|
9
11
|
# @return [Array<Money>] the list of newly allocated Money objects
|
|
10
12
|
# @raise [ArgumentError] if the proportions list is empty or sums to zero
|
|
11
13
|
#
|
|
12
14
|
# @example Proportional allocation
|
|
13
15
|
# money = Money.from(10.00, 'USD')
|
|
14
16
|
# money.allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
|
|
17
|
+
#
|
|
18
|
+
# @example Signed allocation
|
|
19
|
+
# money.allocate([-1, 2]) #=> [[USD -10.00], [USD 20.00]]
|
|
15
20
|
def allocate(proportions)
|
|
16
21
|
whole = proportions.sum.to_r
|
|
17
22
|
raise ArgumentError, 'Need at least 1 proportion element' if proportions.empty?
|
|
@@ -57,6 +57,9 @@ module Mint
|
|
|
57
57
|
# @example Round-trip
|
|
58
58
|
# m = Money.from(134120, "BRL")
|
|
59
59
|
# Money.from_hash(m.to_hash) == m #=> true
|
|
60
|
+
#
|
|
61
|
+
# JSON and Rails `as_json` integration are provided by the
|
|
62
|
+
# +money_attribute+ companion gem rather than the core Minting gem.
|
|
60
63
|
def self.from_hash(hash)
|
|
61
64
|
currency = Currency.resolve!(hash[:currency] || hash['currency'])
|
|
62
65
|
amount = currency.normalize_amount(Rational(hash[:amount] || hash['amount']))
|
|
@@ -7,7 +7,12 @@ module Mint
|
|
|
7
7
|
# and decimal separator.
|
|
8
8
|
#
|
|
9
9
|
# @param template [String, Hash, nil] Either a format string with placeholders
|
|
10
|
-
# (%<symbol>s, %<amount>f, %<
|
|
10
|
+
# (%<symbol>s, %<amount>f, %<magnitude>f, %<sign>s, %<currency>s,
|
|
11
|
+
# %<integral>d, %<fractional>d, %<dsymbol>s),
|
|
12
|
+
# `%<amount>` is signed; `%<magnitude>` is always non-negative; `%<fractional>`
|
|
13
|
+
# is the non-negative fractional component; and `%<sign>` is `+`, `-`, or
|
|
14
|
+
# an empty string for zero. Use `%<sign>` when the sign needs explicit
|
|
15
|
+
# placement, especially for amounts between -1 and 1.
|
|
11
16
|
# or a Hash with per-sign keys (:positive, :negative, :zero) each
|
|
12
17
|
# holding a format string. A Hash is convenient for sign-aware formats
|
|
13
18
|
# such as accounting parentheses:
|
|
@@ -44,12 +49,16 @@ module Mint
|
|
|
44
49
|
# money.format('%<currency>s %<amount>f') #=> "USD 1234.56"
|
|
45
50
|
# money.format('%<amount>f %<symbol>s') #=> "1234.56 $"
|
|
46
51
|
# money.format('%<symbol>s%<amount>+f') #=> "$+1234.56"
|
|
52
|
+
# money.format('%<sign>s%<symbol>s%<magnitude>f') #=> "+$1234.56"
|
|
47
53
|
#
|
|
48
54
|
# @example Integral & fractional parts
|
|
49
55
|
# money.format('%<integral>d.%<fractional>02d') #=> "1234.56"
|
|
50
56
|
# price = Money.from(0.99, 'USD')
|
|
51
57
|
# price.format('%<integral>d dollars and %<fractional>02d cents')
|
|
52
58
|
# #=> "0 dollars and 99 cents"
|
|
59
|
+
# loss = Money.from(-0.99, 'USD')
|
|
60
|
+
# loss.format('%<sign>s%<integral>d.%<fractional>02d')
|
|
61
|
+
# #=> "-0.99"
|
|
53
62
|
#
|
|
54
63
|
# @example Per-sign Hash format (accounting parentheses)
|
|
55
64
|
# loss = Money.from(-1234.56, 'USD')
|
|
@@ -52,12 +52,11 @@ module Mint
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def initialize(format, decimal, thousand)
|
|
55
|
-
@format = format
|
|
56
55
|
@decimal = decimal
|
|
57
|
-
|
|
58
|
-
compile
|
|
56
|
+
compile(format:, thousand:)
|
|
59
57
|
end
|
|
60
58
|
|
|
59
|
+
SIGNS = { -1 => '-', 0 => '', 1 => '+' }.freeze
|
|
61
60
|
SUBUNIT_PLACEHOLDER = "\uE000"
|
|
62
61
|
# Matches a digit followed by groups of exactly 3 digits that terminate
|
|
63
62
|
# at a non-digit or end-of-string. Used to insert thousand separators.
|
|
@@ -70,9 +69,12 @@ module Mint
|
|
|
70
69
|
|
|
71
70
|
templates = @has_placeholder ? @templates_by_subunit[currency.subunit] : @templates
|
|
72
71
|
|
|
73
|
-
|
|
72
|
+
cmp = amount <=> 0
|
|
73
|
+
template = templates[cmp] || templates[1]
|
|
74
|
+
sign = SIGNS[cmp]
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
magnitude = amount.abs
|
|
77
|
+
display_amount = @has_negative_template ? magnitude : amount
|
|
76
78
|
integral = display_amount.to_i
|
|
77
79
|
|
|
78
80
|
result = Kernel.format(template,
|
|
@@ -80,6 +82,8 @@ module Mint
|
|
|
80
82
|
dsymbol: @needs_dsymbol && currency.dsymbol,
|
|
81
83
|
symbol: currency.symbol,
|
|
82
84
|
amount: display_amount,
|
|
85
|
+
magnitude:,
|
|
86
|
+
sign:,
|
|
83
87
|
integral: integral,
|
|
84
88
|
fractional: @needs_fractional ? money.fractional.abs : 0)
|
|
85
89
|
apply_separators(result, integral)
|
|
@@ -100,25 +104,27 @@ module Mint
|
|
|
100
104
|
result
|
|
101
105
|
end
|
|
102
106
|
|
|
103
|
-
def compile
|
|
104
|
-
@templates = { -1 =>
|
|
107
|
+
def compile(format:, thousand:)
|
|
108
|
+
@templates = { -1 => format[:negative], 0 => format[:zero], 1 => format[:positive] || Money::DEFAULT_FORMAT }
|
|
105
109
|
@templates.compact!
|
|
106
|
-
|
|
107
|
-
# precision
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
+
|
|
111
|
+
# Inject subunit precision into amount and magnitude f specs that lack
|
|
112
|
+
# an explicit precision. Matches "%<amount>f" or "%<magnitude>f" and
|
|
113
|
+
# appends a placeholder for the currency subunit digits.
|
|
110
114
|
# The placeholder is later replaced with the actual subunit count at
|
|
111
115
|
# format time (e.g. "\uE000" → "2" for USD, "0" for JPY).
|
|
112
|
-
@templates.transform_values!
|
|
116
|
+
@templates.transform_values! do |f|
|
|
117
|
+
f.gsub(/%<(amount|magnitude)>(\s*\+?\d*)f/, "%<\\1>\\2.#{SUBUNIT_PLACEHOLDER}f")
|
|
118
|
+
end
|
|
113
119
|
@has_negative_template = @templates.key?(-1)
|
|
114
|
-
|
|
115
120
|
joined = @templates.values.join
|
|
116
121
|
@needs_fractional = joined.include?('%<fractional>')
|
|
117
122
|
@needs_dsymbol = joined.include?('%<dsymbol>')
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
@needs_thousand_substitution = thousand && !thousand.empty? &&
|
|
124
|
+
(joined.include?('%<amount>') ||
|
|
125
|
+
joined.include?('%<magnitude>') ||
|
|
126
|
+
joined.include?('%<integral>'))
|
|
127
|
+
@thousand_replacement = "\\1#{thousand}" if @needs_thousand_substitution
|
|
122
128
|
|
|
123
129
|
@has_placeholder = joined.include?(SUBUNIT_PLACEHOLDER)
|
|
124
130
|
return unless @has_placeholder
|
data/lib/minting/money/money.rb
CHANGED
|
@@ -52,7 +52,8 @@ module Mint
|
|
|
52
52
|
|
|
53
53
|
alias to_i integral
|
|
54
54
|
|
|
55
|
-
# Returns the fractional part of the amount.
|
|
55
|
+
# Returns the signed fractional part of the amount. Formatting templates
|
|
56
|
+
# expose this value as a magnitude through `%<fractional>d`.
|
|
56
57
|
# @example
|
|
57
58
|
# Money.from(1234.56, 'USD').fractional #=> 56
|
|
58
59
|
# Money.from(1000, 'JPY').fractional #=> 0
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
class Money
|
|
5
|
+
# Parses numeric input when the decimal separator is explicitly known.
|
|
6
|
+
# @api private
|
|
7
|
+
module SeparatorParser
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def parse(numeric, decimal, thousand)
|
|
11
|
+
decimal, thousand = separators(decimal, thousand)
|
|
12
|
+
return nil unless decimal && thousand && decimal != thousand
|
|
13
|
+
|
|
14
|
+
sign = numeric.start_with?('-', '+') ? numeric[0] : ''
|
|
15
|
+
unsigned = numeric.delete_prefix(sign)
|
|
16
|
+
return nil if unsigned.count(decimal) > 1
|
|
17
|
+
|
|
18
|
+
return parse_decimal(sign, unsigned, decimal, thousand) if unsigned.include?(decimal)
|
|
19
|
+
return parse_thousands(sign, unsigned, thousand) if unsigned.include?(thousand)
|
|
20
|
+
return "#{sign}#{unsigned}" if unsigned.match?(/\A\d+\z/)
|
|
21
|
+
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def separators(decimal, thousand)
|
|
26
|
+
return [decimal, thousand] if decimal && thousand
|
|
27
|
+
return [decimal, decimal == ',' ? '.' : ','] if %w[. ,].include?(decimal)
|
|
28
|
+
return [thousand == ',' ? '.' : ',', thousand] if %w[. ,].include?(thousand)
|
|
29
|
+
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
private_class_method :separators
|
|
33
|
+
|
|
34
|
+
def parse_decimal(sign, unsigned, decimal, thousand)
|
|
35
|
+
integral, fractional = unsigned.split(decimal, 2)
|
|
36
|
+
return nil unless valid_integer_part?(integral, thousand) && fractional.match?(/\A\d+\z/)
|
|
37
|
+
|
|
38
|
+
"#{sign}#{integral.delete(thousand)}.#{fractional}"
|
|
39
|
+
end
|
|
40
|
+
private_class_method :parse_decimal
|
|
41
|
+
|
|
42
|
+
def parse_thousands(sign, unsigned, thousand)
|
|
43
|
+
return nil unless valid_integer_part?(unsigned, thousand)
|
|
44
|
+
|
|
45
|
+
"#{sign}#{unsigned.delete(thousand)}"
|
|
46
|
+
end
|
|
47
|
+
private_class_method :parse_thousands
|
|
48
|
+
|
|
49
|
+
def valid_integer_part?(integer, thousand)
|
|
50
|
+
return integer.match?(/\A\d+\z/) unless integer.include?(thousand)
|
|
51
|
+
|
|
52
|
+
groups = integer.split(thousand)
|
|
53
|
+
groups.first.match?(/\A\d{1,3}\z/) && groups.drop(1).all? { |group| group.match?(/\A\d{3}\z/) }
|
|
54
|
+
end
|
|
55
|
+
private_class_method :valid_integer_part?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private_constant :SeparatorParser
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/minting/money/parse.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'parse/separator_parser'
|
|
4
|
+
|
|
3
5
|
module Mint
|
|
4
6
|
# :nodoc:
|
|
5
7
|
class Money
|
|
@@ -8,8 +10,15 @@ module Mint
|
|
|
8
10
|
# Returns +nil+ when the input is invalid or currency cannot be determined.
|
|
9
11
|
#
|
|
10
12
|
# @param input [String] Amount input, optionally including a currency symbol or code
|
|
11
|
-
# @param
|
|
13
|
+
# @param positional_currency [String, Currency, nil] deprecated positional
|
|
14
|
+
# fallback currency when none is present in +input+
|
|
15
|
+
# @param default_currency [String, Currency, nil] fallback currency when none is present in +input+
|
|
12
16
|
# An embedded currency code or symbol takes precedence over this argument.
|
|
17
|
+
# The positional form is deprecated; use +default_currency:+ instead.
|
|
18
|
+
# @param decimal [String, nil] decimal separator used by the input source.
|
|
19
|
+
# When omitted, it is inferred from +thousand:+ or separator positions.
|
|
20
|
+
# @param thousand [String, nil] thousands separator used by the input source.
|
|
21
|
+
# When omitted, it is inferred from +decimal:+ or separator positions.
|
|
13
22
|
# @return [Money, nil]
|
|
14
23
|
#
|
|
15
24
|
# @example With explicit currency
|
|
@@ -19,16 +28,23 @@ module Mint
|
|
|
19
28
|
# @example With symbol or code in the string
|
|
20
29
|
# Money.parse('$19.99') #=> [USD 19.99]
|
|
21
30
|
# Money.parse('USD 1,234.56') #=> [USD 1234.56]
|
|
22
|
-
|
|
31
|
+
# Money.parse('123,456', 'EUR', decimal: ',') #=> [EUR 123.46]
|
|
32
|
+
# Money.parse('123,456', 'USD', thousand: ',') #=> [USD 123456.00]
|
|
33
|
+
# @deprecated Pass the fallback currency as +default_currency:+.
|
|
34
|
+
def self.parse(input, positional_currency = nil, default_currency: nil, decimal: nil, thousand: nil)
|
|
35
|
+
if positional_currency
|
|
36
|
+
warn 'DEPRECATION: Money.parse positional currency is deprecated; use default_currency: instead.', uplevel: 1
|
|
37
|
+
end
|
|
23
38
|
return nil unless input.is_a?(String)
|
|
24
39
|
|
|
25
40
|
input = input.strip
|
|
26
41
|
return nil if input.empty?
|
|
27
42
|
|
|
43
|
+
currency = default_currency || positional_currency
|
|
28
44
|
currency = parse_currency(input, currency)
|
|
29
45
|
return nil unless currency
|
|
30
46
|
|
|
31
|
-
amount = parse_amount(input, currency)
|
|
47
|
+
amount = parse_amount(input, currency, decimal:, thousand:)
|
|
32
48
|
return nil unless amount
|
|
33
49
|
|
|
34
50
|
amount = currency.normalize_amount(amount)
|
|
@@ -38,24 +54,36 @@ module Mint
|
|
|
38
54
|
# Like {.parse} but raises on failure.
|
|
39
55
|
#
|
|
40
56
|
# @param input [String] Amount input, optionally including a currency symbol or code
|
|
41
|
-
# @param
|
|
57
|
+
# @param positional_currency [String, Currency, nil] deprecated positional
|
|
58
|
+
# fallback currency when none is present in +input+
|
|
59
|
+
# @param default_currency [String, Currency, nil] fallback currency when none is present in +input+
|
|
42
60
|
# An embedded currency code or symbol takes precedence over this argument.
|
|
61
|
+
# The positional form is deprecated; use +default_currency:+ instead.
|
|
62
|
+
# @param decimal [String, nil] decimal separator used by the input source.
|
|
63
|
+
# When omitted, it is inferred from +thousand:+ or separator positions.
|
|
64
|
+
# @param thousand [String, nil] thousands separator used by the input source.
|
|
65
|
+
# When omitted, it is inferred from +decimal:+ or separator positions.
|
|
43
66
|
# @return [Money]
|
|
44
67
|
# @raise [ArgumentError] when +input+ is invalid or currency cannot be determined
|
|
45
68
|
#
|
|
46
69
|
# @example
|
|
47
70
|
# Money.parse!('19.99', 'USD') #=> [USD 19.99]
|
|
48
71
|
# Money.parse!('garbage', 'USD') #=> ArgumentError
|
|
49
|
-
|
|
72
|
+
# @deprecated Pass the fallback currency as +default_currency:+.
|
|
73
|
+
def self.parse!(input, positional_currency = nil, default_currency: nil, decimal: nil, thousand: nil)
|
|
74
|
+
if positional_currency
|
|
75
|
+
warn 'DEPRECATION: Money.parse! positional currency is deprecated; use default_currency: instead.', uplevel: 1
|
|
76
|
+
end
|
|
50
77
|
raise ArgumentError, 'input must be a String' unless input.is_a?(String)
|
|
51
78
|
|
|
52
79
|
input = input.strip
|
|
53
80
|
raise ArgumentError, 'input cannot be empty' if input.empty?
|
|
54
81
|
|
|
82
|
+
currency = default_currency || positional_currency
|
|
55
83
|
currency = parse_currency(input, currency)
|
|
56
84
|
raise ArgumentError, "Currency [#{currency}] not found" unless currency
|
|
57
85
|
|
|
58
|
-
amount = parse_amount(input, currency)
|
|
86
|
+
amount = parse_amount(input, currency, decimal:, thousand:)
|
|
59
87
|
raise ArgumentError, "Could not parse [#{input}]" unless amount
|
|
60
88
|
|
|
61
89
|
amount = currency.normalize_amount(amount)
|
|
@@ -67,7 +95,7 @@ module Mint
|
|
|
67
95
|
|
|
68
96
|
# Extracts one valid numeric value, allowing surrounding currency markers
|
|
69
97
|
# and uppercase annotation words (for example, "MAX 10.00 USD").
|
|
70
|
-
def parse_amount(input, currency)
|
|
98
|
+
def parse_amount(input, currency, decimal: nil, thousand: nil)
|
|
71
99
|
accounting_negative = input.start_with?('(') && input.end_with?(')')
|
|
72
100
|
return nil if (input.include?('(') || input.include?(')')) && !accounting_negative
|
|
73
101
|
|
|
@@ -77,7 +105,7 @@ module Mint
|
|
|
77
105
|
numeric_input.sub!(/\A([+-])\s+/, '\\1')
|
|
78
106
|
return nil unless numeric_input.match?(/\A[+-]?\d[\d.,]*\z/)
|
|
79
107
|
|
|
80
|
-
numeric = parse_separators(numeric_input)
|
|
108
|
+
numeric = parse_separators(numeric_input, decimal:, thousand:)
|
|
81
109
|
return nil unless numeric
|
|
82
110
|
|
|
83
111
|
amount = Rational(numeric)
|
|
@@ -106,8 +134,15 @@ module Mint
|
|
|
106
134
|
Currency.resolve(currency)
|
|
107
135
|
end
|
|
108
136
|
|
|
109
|
-
# Converts
|
|
110
|
-
|
|
137
|
+
# Converts decimal/thousand separators into a plain decimal string.
|
|
138
|
+
# An explicit decimal separator resolves otherwise ambiguous values.
|
|
139
|
+
def parse_separators(numeric, decimal: nil, thousand: nil)
|
|
140
|
+
return SeparatorParser.parse(numeric, decimal, thousand) if decimal || thousand
|
|
141
|
+
|
|
142
|
+
parse_heuristic_separators(numeric)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_heuristic_separators(numeric)
|
|
111
146
|
return nil unless numeric.match?(/\d/)
|
|
112
147
|
return nil unless valid_numeric_syntax?(numeric)
|
|
113
148
|
|
|
@@ -9,11 +9,10 @@ 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 activated on first call.
|
|
13
|
-
# +Currency#normalize_amount+
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# incurs zero overhead.
|
|
12
|
+
# Rounding-mode support is activated on first call. Before activation,
|
|
13
|
+
# +Currency#normalize_amount+ uses a direct +Rational#round+ fast path with
|
|
14
|
+
# no thread-local lookup. Once activated, normalization reads the
|
|
15
|
+
# thread-local mode so custom rounding remains isolated per thread.
|
|
17
16
|
#
|
|
18
17
|
# @param mode [Symbol] one of: +:up+, +:down+, +:even+
|
|
19
18
|
# @yield block to execute with the rounding mode active
|
data/lib/minting/version.rb
CHANGED
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.
|
|
4
|
+
version: 2.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gilson Ferraz
|
|
@@ -79,6 +79,7 @@ files:
|
|
|
79
79
|
- lib/minting/money/format/validator.rb
|
|
80
80
|
- lib/minting/money/money.rb
|
|
81
81
|
- lib/minting/money/parse.rb
|
|
82
|
+
- lib/minting/money/parse/separator_parser.rb
|
|
82
83
|
- lib/minting/money/rounding.rb
|
|
83
84
|
- lib/minting/version.rb
|
|
84
85
|
- minting.gemspec
|