amounts 0.0.2 → 0.0.3

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: cf7f88449ef3636b2ad506b569793e7943f6cd16b82bb1013d8eb7c8a866c41b
4
- data.tar.gz: 49e3027de827e8cd1747fd5df795e3a605736a6192298cc403a1a9de19ad158f
3
+ metadata.gz: 95dbf17ecb45d86b36277c6ecdaf611bdcb99d12eb97a3fc42b5f3bf2cd1ec50
4
+ data.tar.gz: 67fcceae2bae4582f4c4e2866f453727a23f444703e47717ab9e7420e8ff20de
5
5
  SHA512:
6
- metadata.gz: 51e5cc2f4c0e9c1cad9289933283c9d687c36618edb09ed7524c7a366e104c98d46386ebafac1db67f129c72a2605248d3a0bf44b4962cc83aab4ae7154c7af6
7
- data.tar.gz: 738dcbbc4f4aae614d002f4c127ecdb820585e0fee51ec61ddc9381d55acd264ea7db87d1150afb15a96bd803af03d9f2dbd9b672415d4fdf81ec3400e67f06c
6
+ metadata.gz: 15833dd17e8b167cfc61812af65dc0addad29912459a3886df4bd54b01cd8c43d055654cd971838a1c9b5fc76bca8cf83e8eaa72e25eaa4901eef93177275e4a
7
+ data.tar.gz: 002e0c5539b2ed22551a87d0d696a8cbfd2b87565b3c991a91fbbacb3c81e722d3b8b1af62bf1aa0604e08691805bfb2375cf2015f06bd57c62228525b9454ca
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.3 - 2026-04-26
4
+
5
+ ### Fixed
6
+
7
+ - Rational scalars and rates are now accepted everywhere they are documented to
8
+ work: `Amount#*`, `Amount#/`, `Amount#to(rate:)`, `Amount.register_default_rate`,
9
+ and `display_units[unit][:scale]`. Previously each of these called
10
+ `BigDecimal(value.to_s)`, which raises `ArgumentError` for `Rational#to_s`
11
+ (`"3/2"`). All five sites now go through a single internal helper,
12
+ `Amount.coerce_decimal`, that handles `BigDecimal`, `Rational`, and string-or-
13
+ numeric inputs uniformly.
14
+ - `Amount.load` wraps a missing `:atomic` or `:symbol` key as
15
+ `Amount::InvalidInput` (`"amount payload missing key: atomic"`) instead of
16
+ leaking a raw `KeyError`. This matches the AR adapter's `Type#cast_hash`
17
+ behavior so callers can rescue a single error class.
18
+
3
19
  ## 0.0.2 - 2026-04-26
4
20
 
5
21
  ### Fixed
@@ -32,7 +32,7 @@ class Amount
32
32
  # @return [BigDecimal]
33
33
  def in_unit(unit)
34
34
  unit_spec = fetch_display_unit(unit)
35
- @amount.decimal * BigDecimal(unit_spec[:scale].to_s)
35
+ @amount.decimal * Amount.coerce_decimal(unit_spec[:scale])
36
36
  end
37
37
 
38
38
  private
@@ -44,7 +44,7 @@ class Amount
44
44
 
45
45
  def render_display_unit(unit, direction)
46
46
  spec = fetch_display_unit(unit)
47
- scaled = @amount.decimal * BigDecimal(spec[:scale].to_s)
47
+ scaled = @amount.decimal * Amount.coerce_decimal(spec[:scale])
48
48
  decimals = spec[:ui_decimals] || @entry.ui_decimals
49
49
  rounded = round(scaled, decimals, direction)
50
50
 
@@ -151,7 +151,7 @@ class Amount
151
151
 
152
152
  @lock.synchronize do
153
153
  ensure_unlocked!
154
- @default_rates[[from, to]] = BigDecimal(rate.to_s)
154
+ @default_rates[[from, to]] = Amount.coerce_decimal(rate)
155
155
  end
156
156
  end
157
157
 
@@ -22,6 +22,8 @@ class Amount
22
22
  payload.fetch(:symbol) { payload.fetch("symbol") },
23
23
  from: :atomic
24
24
  )
25
+ rescue KeyError => e
26
+ raise InvalidInput, "amount payload missing key: #{e.key}"
25
27
  end
26
28
 
27
29
  def self.validate_version!(version)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Amount
4
- VERSION = "0.0.2"
4
+ VERSION = "0.0.3"
5
5
  end
data/lib/amount.rb CHANGED
@@ -102,6 +102,22 @@ class Amount
102
102
  super
103
103
  end
104
104
 
105
+ # Coerces a numeric input to BigDecimal in a way that preserves Rational
106
+ # values. `BigDecimal(value.to_s)` raises `ArgumentError` for Rational
107
+ # because `Rational#to_s` produces strings like `"3/2"`. This helper is
108
+ # the single place every call site should use to convert a scalar / rate /
109
+ # display-unit scale into a BigDecimal.
110
+ #
111
+ # @param value [Numeric, BigDecimal, Rational, String]
112
+ # @return [BigDecimal]
113
+ def coerce_decimal(value)
114
+ case value
115
+ when BigDecimal then value
116
+ when Rational then BigDecimal(value, Float::DIG + 4)
117
+ else BigDecimal(value.to_s)
118
+ end
119
+ end
120
+
105
121
  # Temporarily swaps the global registry. Intended for tests.
106
122
  #
107
123
  # @param registry [Amount::Registry]
@@ -281,7 +297,7 @@ class Amount
281
297
  # Amount.usdc("1.25") * 2
282
298
  def *(scalar)
283
299
  ensure_scalar!(scalar)
284
- build((BigDecimal(@atomic) * BigDecimal(scalar.to_s)).to_i)
300
+ build((BigDecimal(@atomic) * Amount.coerce_decimal(scalar)).to_i)
285
301
  end
286
302
 
287
303
  # @param other [Amount, Numeric]
@@ -302,7 +318,7 @@ class Amount
302
318
  ensure_scalar!(other)
303
319
  raise ZeroDivisionError if other.zero?
304
320
 
305
- build((BigDecimal(@atomic) / BigDecimal(other.to_s)).to_i)
321
+ build((BigDecimal(@atomic) / Amount.coerce_decimal(other)).to_i)
306
322
  end
307
323
  end
308
324
 
@@ -411,7 +427,7 @@ class Amount
411
427
  rate = resolve_rate(target_symbol, rate)
412
428
  target_entry = self.class.registry.lookup(target_symbol)
413
429
 
414
- decimal_result = decimal * BigDecimal(rate.to_s)
430
+ decimal_result = decimal * Amount.coerce_decimal(rate)
415
431
  atomic_result = (decimal_result * (BigDecimal(10)**target_entry.decimals)).to_i
416
432
 
417
433
  target_entry.amount_class.new(atomic_result, target_symbol, from: :atomic)
data/test/test_amount.rb CHANGED
@@ -229,6 +229,8 @@ class AmountTest < Minitest::Test
229
229
  assert_equal Amount.new("3", :USDC), Amount.new("1", :USDC) * 3
230
230
  assert_equal Amount.new("1.5", :USDC), Amount.new("1", :USDC) * 1.5
231
231
  assert_equal Amount.new("2", :USDC), Amount.new("-1", :USDC) * -2
232
+ assert_equal Amount.new("3", :USDC), Amount.new("2", :USDC) * BigDecimal("1.5")
233
+ assert_equal Amount.new("3", :USDC), Amount.new("2", :USDC) * Rational(3, 2)
232
234
  end
233
235
 
234
236
  def test_amount_times_amount_raises
@@ -240,6 +242,8 @@ class AmountTest < Minitest::Test
240
242
  def test_scalar_division_returns_amount
241
243
  assert_equal Amount.new("0.5", :USDC), Amount.new("1", :USDC) / 2
242
244
  assert_equal Amount.new("2", :USDC), Amount.new("-1", :USDC) / -0.5
245
+ assert_equal Amount.new("2", :USDC), Amount.new("3", :USDC) / Rational(3, 2)
246
+ assert_equal Amount.new("2", :USDC), Amount.new("3", :USDC) / BigDecimal("1.5")
243
247
  end
244
248
 
245
249
  def test_amount_division_returns_ratio
@@ -353,6 +357,33 @@ class AmountTest < Minitest::Test
353
357
  assert_equal :GOLD, gold.symbol
354
358
  end
355
359
 
360
+ def test_to_with_rational_rate
361
+ converted = Amount.new("4", :USDC).to(:USD, rate: Rational(1, 2))
362
+
363
+ assert_equal BigDecimal("2"), converted.decimal
364
+ assert_equal :USD, converted.symbol
365
+ end
366
+
367
+ def test_register_default_rate_accepts_rational
368
+ Amount.register_default_rate :USDC, :USD, Rational(1, 2)
369
+
370
+ assert_equal BigDecimal("0.5"), Amount.registry.default_rate(:USDC, :USD)
371
+ assert_equal BigDecimal("0.5"), Amount.new("1", :USDC).to(:USD).decimal
372
+ end
373
+
374
+ def test_display_units_with_rational_scale
375
+ Amount.registry.clear!
376
+ Amount.register :METAL, decimals: 4, display_symbol: "x", display_position: :suffix,
377
+ ui_decimals: 2,
378
+ display_units: { half: { scale: Rational(1, 2), symbol: "h", ui_decimals: 2 } },
379
+ default_display: :half
380
+
381
+ metal = Amount.new("1", :METAL)
382
+
383
+ assert_equal BigDecimal("0.5"), metal.in_unit(:half)
384
+ assert_match(/\A0\.50/, metal.ui(unit: :half))
385
+ end
386
+
356
387
  def test_to_without_rate_requires_default
357
388
  assert_raises(Amount::Registry::NoDefaultRate) do
358
389
  Amount.new("1", :USDC).to(:GOLD)
@@ -406,6 +437,14 @@ class AmountTest < Minitest::Test
406
437
  assert_equal Amount.new("1.5", :USDC), amount
407
438
  end
408
439
 
440
+ def test_load_wraps_missing_keys_as_invalid_input
441
+ error = assert_raises(Amount::InvalidInput) { Amount.load(v: 1, symbol: "USDC") }
442
+ assert_match(/missing key: atomic/, error.message)
443
+
444
+ error = assert_raises(Amount::InvalidInput) { Amount.load({}) }
445
+ assert_match(/missing key/, error.message)
446
+ end
447
+
409
448
  def test_load_rejects_unknown_serialization_version
410
449
  assert_raises(Amount::InvalidInput) do
411
450
  Amount.load(v: 2, atomic: "1500000", symbol: "USDC")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: amounts
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seb Scholl