amounts 0.0.1 → 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: 92c0c2ebae750b68a79648157559ca2fe6c0c67d5892a83a31beb36e15936648
4
- data.tar.gz: c5b8661d2b980a48a655407fdababfe8428bdd978e1fd7a6d52a6d8a554be287
3
+ metadata.gz: 95dbf17ecb45d86b36277c6ecdaf611bdcb99d12eb97a3fc42b5f3bf2cd1ec50
4
+ data.tar.gz: 67fcceae2bae4582f4c4e2866f453727a23f444703e47717ab9e7420e8ff20de
5
5
  SHA512:
6
- metadata.gz: 2bc8b173facf22c287824621e15f02102cff7cb4e096789e35945a77418815b6027cf9637e0e86bcfc3c427946b1b0aa3f544b01cc338550503aa50b2fa34c7e
7
- data.tar.gz: e4cf1297e4c987f8121d6a9f35f76bc9e668a5f22b130531022428ec0443d019f9ad7d637f098c34be26edaa78d1f105479d935765d3353ef9f42cf8199e3936
6
+ metadata.gz: 15833dd17e8b167cfc61812af65dc0addad29912459a3886df4bd54b01cd8c43d055654cd971838a1c9b5fc76bca8cf83e8eaa72e25eaa4901eef93177275e4a
7
+ data.tar.gz: 002e0c5539b2ed22551a87d0d696a8cbfd2b87565b3c991a91fbbacb3c81e722d3b8b1af62bf1aa0604e08691805bfb2375cf2015f06bd57c62228525b9454ca
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
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
+
19
+ ## 0.0.2 - 2026-04-26
20
+
21
+ ### Fixed
22
+
23
+ - `Amount.new(value, symbol)` now dispatches to the registered subclass when the
24
+ symbol was registered with `class:`, so `Amount.parse`, `Amount.load`, and the
25
+ ActiveRecord adapter (`Type#cast`, `Type#deserialize`) construct the correct
26
+ type instead of raising `InvalidInput`. The previous behavior made it
27
+ impossible to read a custom-class amount back out of an ActiveRecord column.
28
+ The strict-mode error is preserved for explicit wrong-subclass usage
29
+ (`OtherSubclass.new(value, :GOLD)`).
30
+ - `Rational` UI input is now accepted as documented. Previously
31
+ `Amount.new(Rational(3, 2), :USDC)` raised because the value was stringified
32
+ into `"3/2"` before reaching `BigDecimal()`. Rational input is now converted
33
+ via integer math (`(rational * 10**decimals).to_i`), giving exact results for
34
+ finite fractions and well-defined truncate-toward-zero behavior for
35
+ repeating fractions.
36
+ - `AmountValidator` thresholds for multi-symbol `has_amount` attributes accept
37
+ raw numerics. `validates :amount, amount: { greater_than: 0 }` now means
38
+ "greater than zero in the value's symbol" instead of producing
39
+ `"has invalid greater_than constraint: raw numeric assignment requires a
40
+ fixed symbol"`. Fixed-symbol attributes are unchanged.
41
+
3
42
  ## 0.0.1 - 2026-04-25
4
43
 
5
44
  - Initial release.
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![CI](https://github.com/zarpay/amounts/actions/workflows/ci.yml/badge.svg)](https://github.com/zarpay/amounts/actions/workflows/ci.yml)
5
5
  [![Release](https://img.shields.io/github/v/release/zarpay/amounts)](https://github.com/zarpay/amounts/releases)
6
6
  [![License](https://img.shields.io/github/license/zarpay/amounts)](https://github.com/zarpay/amounts/blob/main/LICENSE.txt)
7
- [![Ruby](https://img.shields.io/gem/required-ruby-version/amounts)](https://rubygems.org/gems/amounts)
7
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.1-red)](https://rubygems.org/gems/amounts)
8
8
 
9
9
  `amounts` is a Ruby gem for precise quantities of fungible things: money, crypto tokens, commodities, inventory units, points, and similar value-like amounts. It stores every value as an arbitrary-precision atomic `Integer`, keeps type identity in a registry, rejects accidental cross-type math unless an explicit directional rate exists, and offers an optional ActiveRecord adapter without making Rails part of the core runtime.
10
10
 
@@ -56,7 +56,7 @@ class Amount
56
56
  COMPARATORS.each do |option_name, operator|
57
57
  next unless options.key?(option_name)
58
58
 
59
- other = coerce_comparison_amount(record, attribute, options.fetch(option_name))
59
+ other = coerce_comparison_amount(record, attribute, options.fetch(option_name), value)
60
60
  next unless other
61
61
 
62
62
  compare_amounts(record, attribute, value, operator, other, option_name)
@@ -65,10 +65,13 @@ class Amount
65
65
  end
66
66
  end
67
67
 
68
- def coerce_comparison_amount(record, attribute, candidate)
69
- definition = fetch_definition(record, attribute)
68
+ def coerce_comparison_amount(record, attribute, candidate, value)
70
69
  return candidate if candidate.is_a?(::Amount)
71
70
 
71
+ definition = fetch_definition(record, attribute)
72
+ return definition.type.cast(candidate) if definition.type.fixed_symbol
73
+ return ::Amount.new(candidate, value.symbol, from: :float) if candidate.is_a?(Numeric)
74
+
72
75
  definition.type.cast(candidate)
73
76
  end
74
77
 
@@ -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.1"
4
+ VERSION = "0.0.3"
5
5
  end
data/lib/amount.rb CHANGED
@@ -84,6 +84,40 @@ class Amount
84
84
  Parser.new(str).parse
85
85
  end
86
86
 
87
+ # When called as `Amount.new` for a symbol whose registry entry binds a
88
+ # custom class, dispatch the construction to that class instead of raising.
89
+ # Direct calls to a subclass (`GoldAmount.new(...)`) still go through the
90
+ # default `Class.new` path. Calls that target the wrong subclass continue
91
+ # to raise from `#initialize`.
92
+ #
93
+ # @param value [Integer, String, Float, BigDecimal, Rational]
94
+ # @param symbol [Symbol, String]
95
+ # @param from [Symbol, nil]
96
+ # @return [Amount]
97
+ def new(value, symbol, from: nil)
98
+ if equal?(::Amount)
99
+ entry_class = registry.lookup(symbol.to_sym).amount_class
100
+ return entry_class.new(value, symbol, from:) if entry_class && entry_class != ::Amount
101
+ end
102
+ super
103
+ end
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
+
87
121
  # Temporarily swaps the global registry. Intended for tests.
88
122
  #
89
123
  # @param registry [Amount::Registry]
@@ -139,8 +173,9 @@ class Amount
139
173
  @symbol = symbol.to_sym
140
174
  @entry = self.class.registry.lookup(@symbol)
141
175
 
142
- if @entry.amount_class != self.class && @entry.amount_class != Amount
143
- raise InvalidInput, "use #{@entry.amount_class}.new for #{@symbol}" unless instance_of?(@entry.amount_class)
176
+ expected = @entry.amount_class
177
+ if expected && expected != Amount && self.class != Amount && !instance_of?(expected)
178
+ raise InvalidInput, "use #{expected}.new for #{@symbol}"
144
179
  end
145
180
 
146
181
  @atomic = infer_value(from, value)
@@ -262,7 +297,7 @@ class Amount
262
297
  # Amount.usdc("1.25") * 2
263
298
  def *(scalar)
264
299
  ensure_scalar!(scalar)
265
- build((BigDecimal(@atomic) * BigDecimal(scalar.to_s)).to_i)
300
+ build((BigDecimal(@atomic) * Amount.coerce_decimal(scalar)).to_i)
266
301
  end
267
302
 
268
303
  # @param other [Amount, Numeric]
@@ -283,7 +318,7 @@ class Amount
283
318
  ensure_scalar!(other)
284
319
  raise ZeroDivisionError if other.zero?
285
320
 
286
- build((BigDecimal(@atomic) / BigDecimal(other.to_s)).to_i)
321
+ build((BigDecimal(@atomic) / Amount.coerce_decimal(other)).to_i)
287
322
  end
288
323
  end
289
324
 
@@ -392,7 +427,7 @@ class Amount
392
427
  rate = resolve_rate(target_symbol, rate)
393
428
  target_entry = self.class.registry.lookup(target_symbol)
394
429
 
395
- decimal_result = decimal * BigDecimal(rate.to_s)
430
+ decimal_result = decimal * Amount.coerce_decimal(rate)
396
431
  atomic_result = (decimal_result * (BigDecimal(10)**target_entry.decimals)).to_i
397
432
 
398
433
  target_entry.amount_class.new(atomic_result, target_symbol, from: :atomic)
@@ -454,7 +489,7 @@ class Amount
454
489
  case from || infer_type(value)
455
490
  when :atomic then value.to_i
456
491
  when :ui then ui_to_atomic(value)
457
- when :float then ui_to_atomic(value.to_s)
492
+ when :float then ui_to_atomic(value.is_a?(Rational) ? value : value.to_s)
458
493
  else
459
494
  raise InvalidInput, "unknown amount format: #{value.inspect}"
460
495
  end
@@ -471,6 +506,8 @@ class Amount
471
506
  end
472
507
 
473
508
  def ui_to_atomic(value)
509
+ return (value * (10**decimals)).to_i if value.is_a?(Rational)
510
+
474
511
  (BigDecimal(value.to_s) * (BigDecimal(10)**decimals)).to_i
475
512
  rescue ArgumentError
476
513
  raise InvalidInput, "cannot parse #{value.inspect} as #{symbol}"
@@ -309,4 +309,67 @@ class AmountActiveRecordTest < Minitest::Test
309
309
 
310
310
  assert holding.valid?
311
311
  end
312
+
313
+ def test_amount_validator_accepts_numeric_threshold_on_multi_symbol_attribute
314
+ klass = Class.new(::ActiveRecord::Base) do
315
+ self.table_name = "holdings"
316
+
317
+ has_amount :amount
318
+ validates :amount, amount: { greater_than: 0, less_than_or_equal_to: 100 }
319
+ end
320
+
321
+ above = klass.new(amount: "USDC|50.00")
322
+ assert above.valid?
323
+
324
+ at_zero = klass.new(amount: "USDC|0.00")
325
+ refute at_zero.valid?
326
+ assert_includes at_zero.errors[:amount].join, "greater than"
327
+
328
+ above_max = klass.new(amount: "USDC|150.00")
329
+ refute above_max.valid?
330
+ assert_includes above_max.errors[:amount].join, "less than"
331
+ end
332
+
333
+ def test_amount_validator_numeric_threshold_uses_value_symbol
334
+ klass = Class.new(::ActiveRecord::Base) do
335
+ self.table_name = "holdings"
336
+
337
+ has_amount :amount
338
+ validates :amount, amount: { greater_than: 1 }
339
+ end
340
+
341
+ # The same `greater_than: 1` is interpreted as 1 USDC for a USDC value
342
+ # and as 1 SOL for a SOL value, because the threshold inherits the
343
+ # value's symbol on multi-symbol attributes.
344
+ assert klass.new(amount: "USDC|2.00").valid?
345
+ refute klass.new(amount: "USDC|0.50").valid?
346
+ assert klass.new(amount: "SOL|2").valid?
347
+ refute klass.new(amount: "SOL|0.5").valid?
348
+ end
349
+
350
+ def test_custom_class_symbol_round_trips_through_active_record
351
+ metal_class = Class.new(Amount) do
352
+ def self.name
353
+ "MetalAmount"
354
+ end
355
+ end
356
+ Amount.register :METAL, decimals: 4, class: metal_class
357
+
358
+ ::ActiveRecord::Schema.define do
359
+ create_table :metal_holdings, force: true do |t|
360
+ t.amount :weight, symbol: :METAL, null: false
361
+ end
362
+ end
363
+
364
+ klass = Class.new(::ActiveRecord::Base) do
365
+ self.table_name = "metal_holdings"
366
+ has_amount :weight, symbol: :METAL
367
+ end
368
+
369
+ record = klass.create!(weight: metal_class.new("2.5", :METAL))
370
+ record.reload
371
+
372
+ assert_instance_of metal_class, record.weight
373
+ assert_equal 25_000, record.weight.atomic
374
+ end
312
375
  end
data/test/test_amount.rb CHANGED
@@ -26,6 +26,20 @@ class AmountTest < Minitest::Test
26
26
  assert_equal 1_500_000, Amount.new(1.5, :USDC).atomic
27
27
  end
28
28
 
29
+ def test_construct_from_rational_treats_as_ui
30
+ assert_equal 1_500_000, Amount.new(Rational(3, 2), :USDC).atomic
31
+ end
32
+
33
+ def test_construct_from_rational_is_exact_for_repeating_fractions
34
+ # 1/3 has no finite decimal expansion. With six decimals of storage
35
+ # the atomic representation is 333333 (truncated toward zero).
36
+ assert_equal 333_333, Amount.new(Rational(1, 3), :USDC).atomic
37
+ end
38
+
39
+ def test_construct_from_rational_with_explicit_ui_from
40
+ assert_equal 1_500_000, Amount.new(Rational(3, 2), :USDC, from: :ui).atomic
41
+ end
42
+
29
43
  def test_explicit_from_overrides_inference
30
44
  amount = Amount.new("1500000", :USDC, from: :atomic)
31
45
 
@@ -215,6 +229,8 @@ class AmountTest < Minitest::Test
215
229
  assert_equal Amount.new("3", :USDC), Amount.new("1", :USDC) * 3
216
230
  assert_equal Amount.new("1.5", :USDC), Amount.new("1", :USDC) * 1.5
217
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)
218
234
  end
219
235
 
220
236
  def test_amount_times_amount_raises
@@ -226,6 +242,8 @@ class AmountTest < Minitest::Test
226
242
  def test_scalar_division_returns_amount
227
243
  assert_equal Amount.new("0.5", :USDC), Amount.new("1", :USDC) / 2
228
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")
229
247
  end
230
248
 
231
249
  def test_amount_division_returns_ratio
@@ -339,6 +357,33 @@ class AmountTest < Minitest::Test
339
357
  assert_equal :GOLD, gold.symbol
340
358
  end
341
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
+
342
387
  def test_to_without_rate_requires_default
343
388
  assert_raises(Amount::Registry::NoDefaultRate) do
344
389
  Amount.new("1", :USDC).to(:GOLD)
@@ -392,6 +437,14 @@ class AmountTest < Minitest::Test
392
437
  assert_equal Amount.new("1.5", :USDC), amount
393
438
  end
394
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
+
395
448
  def test_load_rejects_unknown_serialization_version
396
449
  assert_raises(Amount::InvalidInput) do
397
450
  Amount.load(v: 2, atomic: "1500000", symbol: "USDC")
@@ -469,4 +522,35 @@ class AmountCustomClassTest < Minitest::Test
469
522
  assert parts.all? { |part| part.instance_of?(GoldAmount) }
470
523
  assert_instance_of GoldAmount, remainder
471
524
  end
525
+
526
+ def test_amount_new_dispatches_to_registered_subclass
527
+ assert_instance_of GoldAmount, Amount.new("1", :GOLD)
528
+ assert_instance_of GoldAmount, Amount.new(100_000_000, :GOLD, from: :atomic)
529
+ end
530
+
531
+ def test_parse_dispatches_to_registered_subclass
532
+ assert_instance_of GoldAmount, Amount.parse("GOLD|1.0")
533
+ end
534
+
535
+ def test_load_dispatches_to_registered_subclass
536
+ payload = GoldAmount.new("1", :GOLD).to_h
537
+ restored = Amount.load(payload)
538
+
539
+ assert_instance_of GoldAmount, restored
540
+ assert_equal "24k", restored.purity_estimate
541
+ end
542
+
543
+ def test_explicit_wrong_subclass_still_raises
544
+ other = Class.new(Amount)
545
+
546
+ error = assert_raises(Amount::InvalidInput) { other.new("1", :GOLD) }
547
+ assert_match(/use AmountCustomClassTest::GoldAmount\.new for GOLD/, error.message)
548
+ end
549
+
550
+ def test_amount_new_with_default_class_is_unchanged
551
+ Amount.register :USDC, decimals: 6
552
+ instance = Amount.new("1", :USDC)
553
+
554
+ assert_instance_of Amount, instance
555
+ end
472
556
  end
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.1
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seb Scholl