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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +1 -1
- data/lib/amount/active_record/amount_validator.rb +6 -3
- data/lib/amount/version.rb +1 -1
- data/lib/amount.rb +24 -3
- data/test/test_active_record.rb +63 -0
- data/test/test_amount.rb +45 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf7f88449ef3636b2ad506b569793e7943f6cd16b82bb1013d8eb7c8a866c41b
|
|
4
|
+
data.tar.gz: 49e3027de827e8cd1747fd5df795e3a605736a6192298cc403a1a9de19ad158f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://github.com/zarpay/amounts/actions/workflows/ci.yml)
|
|
5
5
|
[](https://github.com/zarpay/amounts/releases)
|
|
6
6
|
[](https://github.com/zarpay/amounts/blob/main/LICENSE.txt)
|
|
7
|
-
[](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
|
|
data/lib/amount/version.rb
CHANGED
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
|
-
|
|
143
|
-
|
|
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}"
|
data/test/test_active_record.rb
CHANGED
|
@@ -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
|