amounts 0.0.1

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.
@@ -0,0 +1,472 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class AmountTest < Minitest::Test
6
+ def setup
7
+ AmountTestSupport.register_default_types!
8
+ end
9
+
10
+ def teardown
11
+ Amount.registry.clear!
12
+ end
13
+
14
+ def test_construct_from_integer_treats_as_atomic
15
+ amount = Amount.new(1_000_000, :USDC)
16
+
17
+ assert_equal 1_000_000, amount.atomic
18
+ assert_equal BigDecimal("1"), amount.decimal
19
+ end
20
+
21
+ def test_construct_from_string_treats_as_ui
22
+ assert_equal 1_500_000, Amount.new("1.50", :USDC).atomic
23
+ end
24
+
25
+ def test_construct_from_float_treats_as_decimal
26
+ assert_equal 1_500_000, Amount.new(1.5, :USDC).atomic
27
+ end
28
+
29
+ def test_explicit_from_overrides_inference
30
+ amount = Amount.new("1500000", :USDC, from: :atomic)
31
+
32
+ assert_equal 1_500_000, amount.atomic
33
+ assert_equal BigDecimal("1.5"), amount.decimal
34
+ end
35
+
36
+ def test_parse_roundtrips_to_s
37
+ amount = Amount.parse("USDC|1.5")
38
+
39
+ assert_equal 1_500_000, amount.atomic
40
+ assert_equal "USDC|1.5", amount.to_s
41
+ end
42
+
43
+ def test_parse_accepts_explicit_v1_prefix
44
+ amount = Amount.parse("v1:USDC|1.5")
45
+
46
+ assert_equal Amount.new("1.5", :USDC), amount
47
+ end
48
+
49
+ def test_parse_rejects_unknown_version_prefix
50
+ assert_raises(Amount::InvalidInput) { Amount.parse("v2:USDC|1.5") }
51
+ end
52
+
53
+ def test_generated_constructor_matches_new
54
+ assert_equal Amount.new("1.50", :USDC), Amount.usdc("1.50")
55
+ end
56
+
57
+ def test_generated_constructor_accepts_from_keyword
58
+ assert_equal 1_500_000, Amount.usdc(1_500_000, from: :atomic).atomic
59
+ end
60
+
61
+ def test_multi_word_symbol_generates_constructor
62
+ Amount.register :OIL_WTI_BBL, decimals: 4
63
+
64
+ assert_equal Amount.new("1.5", :OIL_WTI_BBL), Amount.oil_wti_bbl("1.5")
65
+ end
66
+
67
+ def test_non_method_safe_symbol_skips_constructor_generation
68
+ Amount.register :'USDC.e', decimals: 6
69
+
70
+ refute Amount.respond_to?(:usdc_e)
71
+ end
72
+
73
+ def test_clear_removes_generated_constructor_methods
74
+ assert Amount.respond_to?(:usdc)
75
+
76
+ Amount.registry.clear!
77
+
78
+ refute Amount.respond_to?(:usdc)
79
+ end
80
+
81
+ def test_registry_lock_prevents_mutation
82
+ Amount.registry.lock!
83
+
84
+ assert Amount.registry.locked?
85
+ assert_raises(Amount::Registry::RegistryLocked) { Amount.register(:DOGE, decimals: 8) }
86
+ assert_raises(Amount::Registry::RegistryLocked) { Amount.register_default_rate(:USD, :USDC, 1) }
87
+ assert_raises(Amount::Registry::RegistryLocked) { Amount.registry.clear! }
88
+ ensure
89
+ Amount.send(:replace_registry, Amount::Registry.new)
90
+ end
91
+
92
+ def test_constructor_collision_raises_at_registration_time
93
+ assert_raises(Amount::Registry::AlreadyRegistered) do
94
+ Amount.register :NEW, decimals: 2
95
+ end
96
+ end
97
+
98
+ def test_inspect_uses_default_ui_representation
99
+ assert_equal "#<Amount USDC $1.50>", Amount.new("1.5", :USDC).inspect
100
+ assert_equal "#<Amount GOLD 1.0000 oz t>", Amount.new("1", :GOLD).inspect
101
+ end
102
+
103
+ def test_parse_rejects_missing_value
104
+ assert_raises(Amount::InvalidInput) { Amount.parse("USDC|") }
105
+ end
106
+
107
+ def test_parse_rejects_missing_symbol
108
+ assert_raises(Amount::InvalidInput) { Amount.parse("|1.5") }
109
+ end
110
+
111
+ def test_unregistered_type_raises
112
+ assert_raises(Amount::Registry::UnknownType) { Amount.new(1, :DOGE) }
113
+ end
114
+
115
+ def test_formatted_respects_storage_decimals
116
+ assert_equal "1.500000", Amount.new("1.5", :USDC).formatted
117
+ assert_equal "1.500000000", Amount.new("1.5", :SOL).formatted
118
+ assert_equal "1", Amount.new(1, :LOGS).formatted
119
+ end
120
+
121
+ def test_ui_respects_ui_decimals_and_position
122
+ assert_equal "$1.50", Amount.new("1.5", :USDC).ui
123
+ assert_equal "$1.56", Amount.new("1.567", :USDC).ui
124
+ assert_equal "1.5000 SOL", Amount.new("1.5", :SOL).ui
125
+ assert_equal "1 logs", Amount.new(1, :LOGS).ui
126
+ end
127
+
128
+ def test_ui_ceil_direction
129
+ assert_equal "$1.57", Amount.new("1.561", :USDC).ui(direction: :ceil)
130
+ end
131
+
132
+ def test_display_units_scale_output
133
+ gold = Amount.new("1.5", :GOLD)
134
+
135
+ assert_equal "1.5000 oz t", gold.ui
136
+ assert_equal "46.65 g", gold.ui(unit: :gram)
137
+ assert_equal "0.04665 kg", gold.ui(unit: :kg)
138
+ end
139
+
140
+ def test_in_unit_returns_raw_bigdecimal
141
+ gold = Amount.new("1.0", :GOLD)
142
+
143
+ assert_in_delta 31.1035, gold.in_unit(:gram).to_f, 0.0001
144
+ end
145
+
146
+ def test_unknown_display_unit_raises
147
+ assert_raises(Amount::Registry::InvalidDisplayUnit) do
148
+ Amount.new("1", :GOLD).ui(unit: :pound)
149
+ end
150
+ end
151
+
152
+ def test_predicates
153
+ assert Amount.new(0, :USDC).zero?
154
+ assert Amount.new(1, :USDC).positive?
155
+ assert Amount.new(-1, :USDC).negative?
156
+ end
157
+
158
+ def test_same_type
159
+ assert Amount.new(1, :USDC).same_type?(Amount.new(2, :USDC))
160
+ refute Amount.new(1, :USDC).same_type?(Amount.new(1, :SOL))
161
+ refute Amount.new(1, :USDC).same_type?(1)
162
+ end
163
+
164
+ def test_addition_same_type
165
+ assert_equal Amount.new("2.0", :USDC), Amount.new("1.5", :USDC) + Amount.new("0.5", :USDC)
166
+ end
167
+
168
+ def test_addition_cross_type_uses_registered_rate
169
+ Amount.register_default_rate :USD, :USDC, 1
170
+
171
+ result = Amount.new("10.00", :USDC) + Amount.new("5.00", :USD)
172
+
173
+ assert_equal Amount.new("15.00", :USDC), result
174
+ end
175
+
176
+ def test_addition_different_type_raises_without_rate
177
+ assert_raises(Amount::TypeMismatch) do
178
+ Amount.new(1, :USDC) + Amount.new(1, :SOL)
179
+ end
180
+ end
181
+
182
+ def test_subtraction_cross_type_uses_registered_rate
183
+ Amount.register_default_rate :USD, :USDC, 1
184
+
185
+ result = Amount.new("10.00", :USDC) - Amount.new("5.00", :USD)
186
+
187
+ assert_equal Amount.new("5.00", :USDC), result
188
+ end
189
+
190
+ def test_subtraction_different_type_raises_without_rate
191
+ assert_raises(Amount::TypeMismatch) do
192
+ Amount.new(1, :USDC) - Amount.new(1, :USD)
193
+ end
194
+ end
195
+
196
+ def test_asymmetric_rate_registration_only_allows_configured_direction
197
+ Amount.register_default_rate :USD, :USDC, 1
198
+
199
+ assert_equal Amount.new("15.00", :USDC), Amount.new("10.00", :USDC) + Amount.new("5.00", :USD)
200
+
201
+ assert_raises(Amount::TypeMismatch) do
202
+ Amount.new("5.00", :USD) + Amount.new("10.00", :USDC)
203
+ end
204
+ end
205
+
206
+ def test_unary_minus
207
+ assert_equal Amount.new("-1", :USDC), -Amount.new("1", :USDC)
208
+ end
209
+
210
+ def test_abs
211
+ assert_equal Amount.new("1", :USDC), Amount.new("-1", :USDC).abs
212
+ end
213
+
214
+ def test_scalar_multiplication
215
+ assert_equal Amount.new("3", :USDC), Amount.new("1", :USDC) * 3
216
+ assert_equal Amount.new("1.5", :USDC), Amount.new("1", :USDC) * 1.5
217
+ assert_equal Amount.new("2", :USDC), Amount.new("-1", :USDC) * -2
218
+ end
219
+
220
+ def test_amount_times_amount_raises
221
+ assert_raises(Amount::TypeMismatch) do
222
+ Amount.new("1", :USDC) * Amount.new("1", :USDC)
223
+ end
224
+ end
225
+
226
+ def test_scalar_division_returns_amount
227
+ assert_equal Amount.new("0.5", :USDC), Amount.new("1", :USDC) / 2
228
+ assert_equal Amount.new("2", :USDC), Amount.new("-1", :USDC) / -0.5
229
+ end
230
+
231
+ def test_amount_division_returns_ratio
232
+ ratio = Amount.new("10", :USDC) / Amount.new("2", :USDC)
233
+
234
+ assert_equal BigDecimal(5), ratio
235
+ end
236
+
237
+ def test_division_by_zero
238
+ assert_raises(ZeroDivisionError) { Amount.new(1, :USDC) / 0 }
239
+ end
240
+
241
+ def test_split_preserves_total_exactly_with_remainder
242
+ amount = Amount.new(10, :LOGS)
243
+ parts, remainder = amount.split(3)
244
+
245
+ assert_equal [3, 3, 3], parts.map(&:atomic)
246
+ assert_equal 1, remainder.atomic
247
+ assert_equal amount.atomic, parts.sum(&:atomic) + remainder.atomic
248
+ end
249
+
250
+ def test_split_even_division_returns_zero_remainder
251
+ parts, remainder = Amount.new(9, :LOGS).split(3)
252
+
253
+ assert_equal [3, 3, 3], parts.map(&:atomic)
254
+ assert_equal 0, remainder.atomic
255
+ end
256
+
257
+ def test_split_one_returns_same_amount_and_zero_remainder
258
+ parts, remainder = Amount.new(9, :LOGS).split(1)
259
+
260
+ assert_equal [9], parts.map(&:atomic)
261
+ assert_equal 0, remainder.atomic
262
+ end
263
+
264
+ def test_split_negative_amount_rounds_toward_zero_with_negative_remainder
265
+ parts, remainder = Amount.new(-10, :LOGS).split(3)
266
+
267
+ assert_equal [-3, -3, -3], parts.map(&:atomic)
268
+ assert_equal(-1, remainder.atomic)
269
+ assert_equal(-10, parts.sum(&:atomic) + remainder.atomic)
270
+ end
271
+
272
+ def test_allocate_by_weights
273
+ amount = Amount.new(100, :LOGS)
274
+ parts, remainder = amount.allocate([1, 1, 2])
275
+
276
+ assert_equal [25, 25, 50], parts.map(&:atomic)
277
+ assert_equal 0, remainder.atomic
278
+ assert_equal amount.atomic, parts.sum(&:atomic) + remainder.atomic
279
+ end
280
+
281
+ def test_allocate_remainder_is_explicit
282
+ parts, remainder = Amount.new(10, :LOGS).allocate([1, 1, 1])
283
+
284
+ assert_equal [3, 3, 3], parts.map(&:atomic)
285
+ assert_equal 1, remainder.atomic
286
+ end
287
+
288
+ def test_allocate_allows_zero_weights
289
+ parts, remainder = Amount.new(10, :LOGS).allocate([0, 1])
290
+
291
+ assert_equal [0, 10], parts.map(&:atomic)
292
+ assert_equal 0, remainder.atomic
293
+ end
294
+
295
+ def test_allocate_negative_amount_rounds_toward_zero_with_negative_remainder
296
+ parts, remainder = Amount.new(-10, :LOGS).allocate([1, 1, 1])
297
+
298
+ assert_equal [-3, -3, -3], parts.map(&:atomic)
299
+ assert_equal(-1, remainder.atomic)
300
+ assert_equal(-10, parts.sum(&:atomic) + remainder.atomic)
301
+ end
302
+
303
+ def test_comparison_same_type
304
+ left = Amount.new("1", :USDC)
305
+ right = Amount.new("2", :USDC)
306
+
307
+ assert left < right
308
+ assert right > left
309
+ assert_equal Amount.new("1", :USDC), left
310
+ end
311
+
312
+ def test_comparison_cross_type_uses_registered_rate
313
+ Amount.register_default_rate :USD, :USDC, 1
314
+
315
+ assert_operator Amount.new("10.00", :USDC), :>, Amount.new("5.00", :USD)
316
+ assert_equal 0, Amount.new("5.00", :USDC) <=> Amount.new("5.00", :USD)
317
+ end
318
+
319
+ def test_comparison_different_type_nil_without_rate
320
+ assert_nil(Amount.new(1, :USDC) <=> Amount.new(1, :SOL))
321
+ end
322
+
323
+ def test_sorting
324
+ amounts = [Amount.new("3", :USDC), Amount.new("1", :USDC), Amount.new("2", :USDC)]
325
+
326
+ assert_equal ["1.0", "2.0", "3.0"], amounts.sort.map { |amount| amount.decimal.to_s("F") }
327
+ end
328
+
329
+ def test_to_same_symbol_is_identity
330
+ amount = Amount.new("1", :USDC)
331
+
332
+ assert_equal amount, amount.to(:USDC)
333
+ end
334
+
335
+ def test_to_with_explicit_rate
336
+ gold = Amount.new("1000", :USDC).to(:GOLD, rate: "0.00042")
337
+
338
+ assert_equal BigDecimal("0.42"), gold.decimal
339
+ assert_equal :GOLD, gold.symbol
340
+ end
341
+
342
+ def test_to_without_rate_requires_default
343
+ assert_raises(Amount::Registry::NoDefaultRate) do
344
+ Amount.new("1", :USDC).to(:GOLD)
345
+ end
346
+ end
347
+
348
+ def test_to_uses_registered_default_rate
349
+ Amount.register_default_rate :USDC, :USD, 1
350
+
351
+ usd = Amount.new("1.5", :USDC).to(:USD)
352
+
353
+ assert_equal BigDecimal("1.5"), usd.decimal
354
+ assert_equal :USD, usd.symbol
355
+ end
356
+
357
+ def test_cross_type_addition_via_explicit_conversion
358
+ gold = Amount.new("0.0001", :GOLD)
359
+ usdc = Amount.new("100", :USDC)
360
+ result = gold + usdc.to(:GOLD, rate: "0.00000042")
361
+
362
+ assert_equal BigDecimal("0.000142"), result.decimal
363
+ end
364
+
365
+ def test_hash_equality_semantics
366
+ one = Amount.new("1.5", :USDC)
367
+ two = Amount.new("1.5", :USDC)
368
+ three = Amount.new("1.5", :USD)
369
+
370
+ data = { one => :first }
371
+
372
+ assert_equal :first, data[two]
373
+ assert_nil data[three]
374
+ end
375
+
376
+ def test_to_h_and_load_round_trip_for_large_negative_amount
377
+ amount = Amount.new(-(10**30), :USDC, from: :atomic)
378
+
379
+ assert_equal amount, Amount.load(amount.to_h)
380
+ end
381
+
382
+ def test_to_h_returns_versioned_string_safe_payload
383
+ assert_equal(
384
+ { v: 1, atomic: "1500000", symbol: "USDC" },
385
+ Amount.new("1.5", :USDC).to_h
386
+ )
387
+ end
388
+
389
+ def test_load_accepts_legacy_unversioned_payload
390
+ amount = Amount.load(atomic: 1_500_000, symbol: :USDC)
391
+
392
+ assert_equal Amount.new("1.5", :USDC), amount
393
+ end
394
+
395
+ def test_load_rejects_unknown_serialization_version
396
+ assert_raises(Amount::InvalidInput) do
397
+ Amount.load(v: 2, atomic: "1500000", symbol: "USDC")
398
+ end
399
+ end
400
+
401
+ def test_very_large_atomic_value_preserves_precision
402
+ amount = Amount.new(10**30, :SOL, from: :atomic)
403
+
404
+ assert_equal 10**30, amount.atomic
405
+ assert_equal "1000000000000000000000.0", amount.decimal.to_s("F")
406
+ end
407
+
408
+ def test_registry_can_be_read_from_multiple_threads
409
+ errors = Queue.new
410
+
411
+ threads = 10.times.map do
412
+ Thread.new do
413
+ 100.times do
414
+ Amount.registry.lookup(:USDC)
415
+ Amount.registry.default_rate?(:USDC, :USD)
416
+ rescue StandardError => error
417
+ errors << error
418
+ end
419
+ end
420
+ end
421
+
422
+ threads.each(&:join)
423
+
424
+ assert errors.empty?, "expected no thread errors, got #{errors.size}"
425
+ end
426
+ end
427
+
428
+ class AmountCustomClassTest < Minitest::Test
429
+ class GoldAmount < Amount
430
+ def purity_estimate
431
+ "24k"
432
+ end
433
+ end
434
+
435
+ def setup
436
+ Amount.registry.clear!
437
+ Amount.register :GOLD, decimals: 8, display_symbol: "oz t",
438
+ display_position: :suffix, ui_decimals: 4,
439
+ class: GoldAmount
440
+ end
441
+
442
+ def teardown
443
+ Amount.registry.clear!
444
+ end
445
+
446
+ def test_subclass_instance_from_own_new
447
+ gold = GoldAmount.new("1", :GOLD)
448
+
449
+ assert_instance_of GoldAmount, gold
450
+ assert_equal "24k", gold.purity_estimate
451
+ end
452
+
453
+ def test_arithmetic_returns_subclass
454
+ gold = GoldAmount.new("1", :GOLD)
455
+
456
+ assert_instance_of GoldAmount, gold + gold
457
+ end
458
+
459
+ def test_generated_constructor_returns_subclass
460
+ gold = Amount.gold("1", from: :ui)
461
+
462
+ assert_instance_of GoldAmount, gold
463
+ assert_equal "24k", gold.purity_estimate
464
+ end
465
+
466
+ def test_split_returns_subclass_parts_and_remainder
467
+ parts, remainder = GoldAmount.new("3", :GOLD).split(2)
468
+
469
+ assert parts.all? { |part| part.instance_of?(GoldAmount) }
470
+ assert_instance_of GoldAmount, remainder
471
+ end
472
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "support/amount_test_support"
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: amounts
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Seb Scholl
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bigdecimal
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: |
28
+ Amounts provides a precise Amount value object with atomic integer storage,
29
+ safe registered-type arithmetic, explicit conversion rates, display helpers,
30
+ and an optional ActiveRecord integration layer.
31
+ email:
32
+ - opensource@example.com
33
+ executables:
34
+ - console
35
+ - setup
36
+ extensions: []
37
+ extra_rdoc_files: []
38
+ files:
39
+ - ".rubocop.yml"
40
+ - CHANGELOG.md
41
+ - Gemfile
42
+ - LICENSE.txt
43
+ - README.md
44
+ - Rakefile
45
+ - bin/console
46
+ - bin/setup
47
+ - lib/amount.rb
48
+ - lib/amount/active_record.rb
49
+ - lib/amount/active_record/amount_validator.rb
50
+ - lib/amount/active_record/attribute_definition.rb
51
+ - lib/amount/active_record/migration_methods.rb
52
+ - lib/amount/active_record/model.rb
53
+ - lib/amount/active_record/rspec.rb
54
+ - lib/amount/active_record/type.rb
55
+ - lib/amount/display.rb
56
+ - lib/amount/parser.rb
57
+ - lib/amount/registry.rb
58
+ - lib/amount/registry/generated_constructors.rb
59
+ - lib/amount/rspec.rb
60
+ - lib/amount/rspec_matchers.rb
61
+ - lib/amount/rspec_support.rb
62
+ - lib/amount/serializer.rb
63
+ - lib/amount/version.rb
64
+ - test/dummy/app/models/holding.rb
65
+ - test/dummy/bin/rails
66
+ - test/dummy/config/application.rb
67
+ - test/dummy/config/database.yml
68
+ - test/dummy/config/environment.rb
69
+ - test/dummy/db/schema.rb
70
+ - test/dummy/log/development.log
71
+ - test/dummy/log/test.log
72
+ - test/postgresql_integration_test.rb
73
+ - test/support/amount_test_support.rb
74
+ - test/test_active_record.rb
75
+ - test/test_amount.rb
76
+ - test/test_helper.rb
77
+ homepage: https://github.com/zarpay/amounts
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ bug_tracker_uri: https://github.com/zarpay/amounts/issues
82
+ changelog_uri: https://github.com/zarpay/amounts/blob/main/CHANGELOG.md
83
+ documentation_uri: https://github.com/zarpay/amounts#readme
84
+ source_code_uri: https://github.com/zarpay/amounts
85
+ post_install_message:
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '3.1'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 3.5.22
101
+ signing_key:
102
+ specification_version: 4
103
+ summary: Precise registered quantities for money, tokens, commodities, and other fungible
104
+ things.
105
+ test_files: []