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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4599b829514f80d36867c1ddd7afa3a00a6ea1a718883ce46ea250fc58f4deff
4
- data.tar.gz: cabdea47ab90088ae53582ccacdf9695a6153230aff0f2e986af00191d8eeb05
3
+ metadata.gz: 3af2c270bbab98102901e0f9c1aefb22966d916805cd04a7eab8e867fe9f59be
4
+ data.tar.gz: 109d647aceff1385f35135c91b1728237965e549de4744717ff28956f4e33659
5
5
  SHA512:
6
- metadata.gz: a1bf6f69bb7513c6ec7b3a0b1e3838e0a87add33c57b82b652d8c4e8951539abfccdbd0cf3fa42220a9dc0efce6b64afb426aebd63c2c4ae3e10614d5245e4c5
7
- data.tar.gz: 5e896ab547e87ed89c6d2e30252840b43bf5a4808ba49f82ce2b9dcb55a7d9ccaffe75d0f7ec6c228d7d1bad3c93b9bde3112553704a6d1d4e1dd43f636450ed
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.2 is released. The core API (`Money`, `Currency`, formatting, parsing) is stable.
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, named presets (`:accounting`, `:european`), per-sign formats (parentheses for negatives), and a pluggable locale hook.
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 currency argument is a default, not an override: a code or symbol embedded in the string takes precedence (`Money.parse('10 EUR', 'USD')` returns EUR).
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 when that distribution is
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`. The `%<amount>f` specifier has the currency's subunit
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
- Registry.register(code:, subunit:, symbol:, priority:)
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! = @custom_rounding_active = true
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, %<currency>s, %<integral>d, %<fractional>d, %<dsymbol>s),
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
- @thousand = thousand
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
- template = templates[amount <=> 0] || templates[1]
72
+ cmp = amount <=> 0
73
+ template = templates[cmp] || templates[1]
74
+ sign = SIGNS[cmp]
74
75
 
75
- display_amount = @has_negative_template && amount < 0 ? -amount : amount
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 => @format[:negative], 0 => @format[:zero], 1 => @format[:positive] || Money::DEFAULT_FORMAT }
107
+ def compile(format:, thousand:)
108
+ @templates = { -1 => format[:negative], 0 => format[:zero], 1 => format[:positive] || Money::DEFAULT_FORMAT }
105
109
  @templates.compact!
106
- # Inject subunit precision into %<amount>f specs that lack an explicit
107
- # precision. Matches "%<amount>f" or "%+10<amount>f" (with optional
108
- # flags/width before the named ref) and appends a placeholder for the
109
- # currency subunit digits e.g. "%<amount>f" "%<amount>\uE000f".
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! { |f| f.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{SUBUNIT_PLACEHOLDER}f") }
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
- @needs_thousand_substitution = @thousand && !@thousand.empty? &&
120
- (joined.include?('%<amount>') || joined.include?('%<integral>'))
121
- @thousand_replacement = "\\1#{@thousand}" if @needs_thousand_substitution
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
@@ -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
@@ -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 currency [String, Currency, nil] default currency when none is present in +input+
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
- def self.parse(input, currency = nil)
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 currency [String, Currency, nil] default currency when none is present in +input+
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
- def self.parse!(input, currency = nil)
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 locale-specific decimal/thousand separators into a plain decimal string.
110
- def parse_separators(numeric)
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. Once activated,
13
- # +Currency#normalize_amount+ dispatches through +Currency.rounding_mode+,
14
- # adding ~10–35&ns of overhead to every money creation or mutation.
15
- # When rounding modes are never used (the common case), the fast path
16
- # incurs zero overhead.
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
@@ -3,5 +3,5 @@
3
3
  # Root namespace for the Minting library.
4
4
  module Minting
5
5
  # Current version of the Minting gem.
6
- VERSION = '2.2.0'
6
+ VERSION = '2.3.0'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minting
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
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