amounts 0.0.1 → 0.0.2

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: cf7f88449ef3636b2ad506b569793e7943f6cd16b82bb1013d8eb7c8a866c41b
4
+ data.tar.gz: 49e3027de827e8cd1747fd5df795e3a605736a6192298cc403a1a9de19ad158f
5
5
  SHA512:
6
- metadata.gz: 2bc8b173facf22c287824621e15f02102cff7cb4e096789e35945a77418815b6027cf9637e0e86bcfc3c427946b1b0aa3f544b01cc338550503aa50b2fa34c7e
7
- data.tar.gz: e4cf1297e4c987f8121d6a9f35f76bc9e668a5f22b130531022428ec0443d019f9ad7d637f098c34be26edaa78d1f105479d935765d3353ef9f42cf8199e3936
6
+ metadata.gz: 51e5cc2f4c0e9c1cad9289933283c9d687c36618edb09ed7524c7a366e104c98d46386ebafac1db67f129c72a2605248d3a0bf44b4962cc83aab4ae7154c7af6
7
+ data.tar.gz: 738dcbbc4f4aae614d002f4c127ecdb820585e0fee51ec61ddc9381d55acd264ea7db87d1150afb15a96bd803af03d9f2dbd9b672415d4fdf81ec3400e67f06c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.2 - 2026-04-26
4
+
5
+ ### Fixed
6
+
7
+ - `Amount.new(value, symbol)` now dispatches to the registered subclass when the
8
+ symbol was registered with `class:`, so `Amount.parse`, `Amount.load`, and the
9
+ ActiveRecord adapter (`Type#cast`, `Type#deserialize`) construct the correct
10
+ type instead of raising `InvalidInput`. The previous behavior made it
11
+ impossible to read a custom-class amount back out of an ActiveRecord column.
12
+ The strict-mode error is preserved for explicit wrong-subclass usage
13
+ (`OtherSubclass.new(value, :GOLD)`).
14
+ - `Rational` UI input is now accepted as documented. Previously
15
+ `Amount.new(Rational(3, 2), :USDC)` raised because the value was stringified
16
+ into `"3/2"` before reaching `BigDecimal()`. Rational input is now converted
17
+ via integer math (`(rational * 10**decimals).to_i`), giving exact results for
18
+ finite fractions and well-defined truncate-toward-zero behavior for
19
+ repeating fractions.
20
+ - `AmountValidator` thresholds for multi-symbol `has_amount` attributes accept
21
+ raw numerics. `validates :amount, amount: { greater_than: 0 }` now means
22
+ "greater than zero in the value's symbol" instead of producing
23
+ `"has invalid greater_than constraint: raw numeric assignment requires a
24
+ fixed symbol"`. Fixed-symbol attributes are unchanged.
25
+
3
26
  ## 0.0.1 - 2026-04-25
4
27
 
5
28
  - 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
 
@@ -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.2"
5
5
  end
data/lib/amount.rb CHANGED
@@ -84,6 +84,24 @@ 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
+
87
105
  # Temporarily swaps the global registry. Intended for tests.
88
106
  #
89
107
  # @param registry [Amount::Registry]
@@ -139,8 +157,9 @@ class Amount
139
157
  @symbol = symbol.to_sym
140
158
  @entry = self.class.registry.lookup(@symbol)
141
159
 
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)
160
+ expected = @entry.amount_class
161
+ if expected && expected != Amount && self.class != Amount && !instance_of?(expected)
162
+ raise InvalidInput, "use #{expected}.new for #{@symbol}"
144
163
  end
145
164
 
146
165
  @atomic = infer_value(from, value)
@@ -454,7 +473,7 @@ class Amount
454
473
  case from || infer_type(value)
455
474
  when :atomic then value.to_i
456
475
  when :ui then ui_to_atomic(value)
457
- when :float then ui_to_atomic(value.to_s)
476
+ when :float then ui_to_atomic(value.is_a?(Rational) ? value : value.to_s)
458
477
  else
459
478
  raise InvalidInput, "unknown amount format: #{value.inspect}"
460
479
  end
@@ -471,6 +490,8 @@ class Amount
471
490
  end
472
491
 
473
492
  def ui_to_atomic(value)
493
+ return (value * (10**decimals)).to_i if value.is_a?(Rational)
494
+
474
495
  (BigDecimal(value.to_s) * (BigDecimal(10)**decimals)).to_i
475
496
  rescue ArgumentError
476
497
  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
 
@@ -469,4 +483,35 @@ class AmountCustomClassTest < Minitest::Test
469
483
  assert parts.all? { |part| part.instance_of?(GoldAmount) }
470
484
  assert_instance_of GoldAmount, remainder
471
485
  end
486
+
487
+ def test_amount_new_dispatches_to_registered_subclass
488
+ assert_instance_of GoldAmount, Amount.new("1", :GOLD)
489
+ assert_instance_of GoldAmount, Amount.new(100_000_000, :GOLD, from: :atomic)
490
+ end
491
+
492
+ def test_parse_dispatches_to_registered_subclass
493
+ assert_instance_of GoldAmount, Amount.parse("GOLD|1.0")
494
+ end
495
+
496
+ def test_load_dispatches_to_registered_subclass
497
+ payload = GoldAmount.new("1", :GOLD).to_h
498
+ restored = Amount.load(payload)
499
+
500
+ assert_instance_of GoldAmount, restored
501
+ assert_equal "24k", restored.purity_estimate
502
+ end
503
+
504
+ def test_explicit_wrong_subclass_still_raises
505
+ other = Class.new(Amount)
506
+
507
+ error = assert_raises(Amount::InvalidInput) { other.new("1", :GOLD) }
508
+ assert_match(/use AmountCustomClassTest::GoldAmount\.new for GOLD/, error.message)
509
+ end
510
+
511
+ def test_amount_new_with_default_class_is_unchanged
512
+ Amount.register :USDC, decimals: 6
513
+ instance = Amount.new("1", :USDC)
514
+
515
+ assert_instance_of Amount, instance
516
+ end
472
517
  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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seb Scholl