amounts 0.0.7 → 0.1.0

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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +37 -0
  3. data/CHANGELOG.md +142 -1
  4. data/Gemfile +4 -0
  5. data/README.md +218 -4
  6. data/Rakefile +47 -10
  7. data/lib/amount/active_record/amount_validator.rb +9 -18
  8. data/lib/amount/active_record/attribute_definition.rb +3 -1
  9. data/lib/amount/active_record/locale/en.yml +7 -0
  10. data/lib/amount/active_record/model.rb +9 -0
  11. data/lib/amount/active_record/railtie.rb +1 -1
  12. data/lib/amount/active_record.rb +12 -0
  13. data/lib/amount/compact.rb +137 -0
  14. data/lib/amount/{allocation.rb → concerns/allocatable.rb} +1 -1
  15. data/lib/amount/{arithmetic.rb → concerns/calculable.rb} +9 -9
  16. data/lib/amount/concerns/coercible.rb +65 -0
  17. data/lib/amount/concerns/constructible.rb +92 -0
  18. data/lib/amount/{conversion.rb → concerns/convertible.rb} +3 -3
  19. data/lib/amount/concerns/formattable.rb +112 -0
  20. data/lib/amount/concerns/localizable.rb +39 -0
  21. data/lib/amount/{comparison.rb → concerns/orderable.rb} +10 -10
  22. data/lib/amount/concerns/parsable.rb +58 -0
  23. data/lib/amount/concerns/registrable.rb +80 -0
  24. data/lib/amount/{serialization.rb → concerns/serializable.rb} +4 -4
  25. data/lib/amount/display.rb +86 -51
  26. data/lib/amount/locale.rb +102 -0
  27. data/lib/amount/registry/generated_constructors.rb +10 -5
  28. data/lib/amount/registry.rb +72 -9
  29. data/lib/amount/rspec.rb +4 -4
  30. data/lib/amount/ui_parser.rb +109 -0
  31. data/lib/amount/version.rb +1 -1
  32. data/lib/amount.rb +61 -205
  33. data/test/amount/active_record/amount_validator_test.rb +201 -0
  34. data/test/amount/active_record/attribute_definition_test.rb +234 -0
  35. data/test/amount/active_record/migration_methods_test.rb +60 -0
  36. data/test/amount/active_record/model_test.rb +40 -0
  37. data/test/amount/active_record/type_test.rb +80 -0
  38. data/test/amount/active_record_test.rb +12 -0
  39. data/test/amount/compact_test.rb +371 -0
  40. data/test/amount/concerns/allocatable_test.rb +166 -0
  41. data/test/amount/concerns/calculable_test.rb +152 -0
  42. data/test/amount/concerns/coercible_test.rb +49 -0
  43. data/test/amount/concerns/constructible_test.rb +113 -0
  44. data/test/amount/concerns/convertible_test.rb +54 -0
  45. data/test/amount/concerns/formattable_test.rb +336 -0
  46. data/test/amount/concerns/localizable_test.rb +59 -0
  47. data/test/amount/concerns/orderable_test.rb +83 -0
  48. data/test/amount/concerns/parsable_test.rb +98 -0
  49. data/test/amount/concerns/registrable_test.rb +67 -0
  50. data/test/amount/concerns/serializable_test.rb +98 -0
  51. data/test/amount/display_test.rb +181 -0
  52. data/test/amount/locale_test.rb +590 -0
  53. data/test/amount/parser_test.rb +29 -0
  54. data/test/amount/registry/generated_constructors_test.rb +94 -0
  55. data/test/amount/registry_test.rb +149 -0
  56. data/test/amount/ui_parser_test.rb +284 -0
  57. data/test/amount_test.rb +55 -0
  58. data/test/dummy/config/application.rb +3 -0
  59. data/test/generators/amount/active_record/registry_generator_test.rb +116 -0
  60. data/test/integration/postgresql_test.rb +94 -0
  61. data/test/support/active_record_test_support.rb +65 -0
  62. data/test/support/error_helpers.rb +22 -0
  63. data/test/support/locale_test_setup.rb +36 -0
  64. data/test/test_helper.rb +1 -0
  65. metadata +47 -11
  66. data/test/postgresql_integration_test.rb +0 -71
  67. data/test/test_active_record.rb +0 -375
  68. data/test/test_amount.rb +0 -663
  69. data/test/test_registry_generator.rb +0 -104
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../test_helper"
4
+ require_relative "../../support/active_record_test_support"
5
+
6
+ class AmountActiveRecordAttributeDefinitionTest < Minitest::Test
7
+ include ActiveRecordTestSetup
8
+
9
+ def test_reader_returns_amount
10
+ holding = Holding.create!(amount_atomic: 1_500_000, amount_symbol: "USDC")
11
+
12
+ assert_equal Amount.new("1.5", :USDC), holding.amount
13
+ end
14
+
15
+ def test_multi_symbol_reader_returns_nil_when_atomic_is_nil
16
+ assert_nil Holding.create!.amount
17
+ end
18
+
19
+ def test_fixed_symbol_reader_returns_nil_when_atomic_is_nil
20
+ assert_nil Holding.create!.fee
21
+ end
22
+
23
+ def test_multi_symbol_writer_stores_amount_atomic
24
+ assert_equal 1_500_000, amount_written_holding.amount_atomic
25
+ end
26
+
27
+ def test_multi_symbol_writer_stores_amount_symbol
28
+ assert_equal "USDC", amount_written_holding.amount_symbol
29
+ end
30
+
31
+ # Rails 8.1 cuts a String cast to an integer column to 16 bytes, so the
32
+ # writer must hand Rails the Integer, never its string form.
33
+ def test_writer_keeps_atomic_values_longer_than_sixteen_digits
34
+ holding = Holding.new(amount: Amount.new(10**30, :SOL, from: :atomic))
35
+
36
+ assert_equal 10**30, holding.amount_atomic
37
+ end
38
+
39
+ def test_fixed_symbol_writer_keeps_atomic_values_longer_than_sixteen_digits
40
+ holding = Holding.new(fee: Amount.new(10**30, :SOL, from: :atomic))
41
+
42
+ assert_equal 10**30, holding.fee_atomic
43
+ end
44
+
45
+ def test_fixed_symbol_writer_stores_amount_atomic
46
+ holding = Holding.new
47
+ holding.fee = Amount.new("2.5", :SOL)
48
+
49
+ assert_equal 2_500_000_000, holding.fee_atomic
50
+ end
51
+
52
+ def test_nil_assignment_clears_atomic_column
53
+ assert_nil nil_assigned_holding.amount_atomic
54
+ end
55
+
56
+ def test_nil_assignment_clears_symbol_column
57
+ assert_nil nil_assigned_holding.amount_symbol
58
+ end
59
+
60
+ def test_nil_assignment_reads_back_nil
61
+ assert_nil nil_assigned_holding.amount
62
+ end
63
+
64
+ def test_where_scope_matches_atomic_and_symbol
65
+ usdc = create_same_value_in_two_symbols
66
+
67
+ assert_equal [usdc.id], Holding.where_amount("USDC|1.5").pluck(:id)
68
+ end
69
+
70
+ def test_in_scope_matches_symbol
71
+ usdc = create_same_value_in_two_symbols
72
+
73
+ assert_equal [usdc.id], Holding.amount_in(:USDC).pluck(:id)
74
+ end
75
+
76
+ def test_gt_scope_filters_multi_symbol_amounts_by_symbol_and_atomic_value
77
+ _low, mid, high = create_usdc_ladder
78
+
79
+ assert_equal [mid.id, high.id], Holding.where_amount_gt("USDC|1.50").pluck(:id)
80
+ end
81
+
82
+ def test_gte_scope_filters_multi_symbol_amounts_by_symbol_and_atomic_value
83
+ _low, mid, high = create_usdc_ladder
84
+
85
+ assert_equal [mid.id, high.id], Holding.where_amount_gte("USDC|2.00").pluck(:id)
86
+ end
87
+
88
+ def test_lt_scope_filters_multi_symbol_amounts_by_symbol_and_atomic_value
89
+ low, mid, _high = create_usdc_ladder
90
+
91
+ assert_equal [low.id, mid.id], Holding.where_amount_lt("USDC|2.50").pluck(:id)
92
+ end
93
+
94
+ def test_lte_scope_filters_multi_symbol_amounts_by_symbol_and_atomic_value
95
+ low, mid, _high = create_usdc_ladder
96
+
97
+ assert_equal [low.id, mid.id], Holding.where_amount_lte("USDC|2.00").pluck(:id)
98
+ end
99
+
100
+ def test_between_scope_includes_both_bounds_for_multi_symbol_amounts
101
+ low, mid, high = create_usdc_ladder(other: "USD|2.00")
102
+
103
+ assert_equal [low.id, mid.id, high.id], Holding.where_amount_between("USDC|1.00", "USDC|3.00").pluck(:id)
104
+ end
105
+
106
+ def test_between_scope_filters_multi_symbol_amounts_inside_bounds
107
+ _low, mid, _high = create_usdc_ladder(other: "USD|2.00")
108
+
109
+ assert_equal [mid.id], Holding.where_amount_between("USDC|1.50", "USDC|2.50").pluck(:id)
110
+ end
111
+
112
+ def test_between_scope_requires_matching_symbols_for_multi_symbol_amounts
113
+ assert_raises(Amount::TypeMismatch) do
114
+ Holding.where_amount_between("USDC|1.00", "USD|2.00").load
115
+ end
116
+ end
117
+
118
+ def test_gt_scope_supports_fixed_symbol_numeric_values
119
+ _low, mid, high = create_fee_ladder
120
+
121
+ assert_equal [mid.id, high.id], Holding.where_fee_gt(0.25).pluck(:id)
122
+ end
123
+
124
+ def test_gte_scope_supports_fixed_symbol_numeric_values
125
+ _low, mid, high = create_fee_ladder
126
+
127
+ assert_equal [mid.id, high.id], Holding.where_fee_gte(0.50).pluck(:id)
128
+ end
129
+
130
+ def test_lt_scope_supports_fixed_symbol_numeric_values
131
+ low, mid, _high = create_fee_ladder
132
+
133
+ assert_equal [low.id, mid.id], Holding.where_fee_lt(0.75).pluck(:id)
134
+ end
135
+
136
+ def test_lte_scope_supports_fixed_symbol_numeric_values
137
+ low, mid, _high = create_fee_ladder
138
+
139
+ assert_equal [low.id, mid.id], Holding.where_fee_lte(0.50).pluck(:id)
140
+ end
141
+
142
+ def test_between_scope_supports_fixed_symbol_numeric_values
143
+ _low, mid, _high = create_fee_ladder
144
+
145
+ assert_equal [mid.id], Holding.where_fee_between(0.30, 0.60).pluck(:id)
146
+ end
147
+
148
+ def test_changed_predicate_uses_virtual_amount_attribute
149
+ assert changed_holding.amount_changed?
150
+ end
151
+
152
+ def test_change_uses_virtual_amount_attribute
153
+ assert_equal [Amount.new("1.0", :USDC), Amount.new("2.5", :USDC)], changed_holding.amount_change
154
+ end
155
+
156
+ def test_saved_change_predicate_uses_virtual_amount_attribute
157
+ assert saved_holding.saved_change_to_amount?
158
+ end
159
+
160
+ def test_before_last_save_uses_virtual_amount_attribute
161
+ assert_equal Amount.new("1.0", :USDC), saved_holding.amount_before_last_save
162
+ end
163
+
164
+ def test_saved_change_uses_virtual_amount_attribute
165
+ assert_equal [Amount.new("1.0", :USDC), Amount.new("2.5", :USDC)], saved_holding.saved_change_to_amount
166
+ end
167
+
168
+ def test_validation_rejects_atomic_without_symbol
169
+ refute Holding.new(amount_atomic: 100).valid?
170
+ end
171
+
172
+ def test_validation_requires_both_amount_columns_or_neither
173
+ holding = Holding.new(amount_atomic: 100)
174
+ holding.valid?
175
+
176
+ assert_includes holding.errors[:amount], "must set both atomic and symbol or neither"
177
+ end
178
+
179
+ def test_group_sum_totals_first_symbol_atomic_column
180
+ assert_equal 3_500_000, grouped_sums["USDC"]
181
+ end
182
+
183
+ def test_group_sum_totals_second_symbol_atomic_column
184
+ assert_equal 300, grouped_sums["USD"]
185
+ end
186
+
187
+ private
188
+
189
+ def amount_written_holding
190
+ Holding.new.tap { |holding| holding.amount = Amount.new("1.5", :USDC) }
191
+ end
192
+
193
+ def nil_assigned_holding
194
+ holding = Holding.create!(amount: Amount.new("1", :USDC))
195
+ holding.amount = nil
196
+ holding
197
+ end
198
+
199
+ # The USDC record; a USD record with the same value is created alongside.
200
+ def create_same_value_in_two_symbols
201
+ usdc = Holding.create!(amount: Amount.new("1.5", :USDC))
202
+ Holding.create!(amount: Amount.new("1.5", :USD))
203
+ usdc
204
+ end
205
+
206
+ # USDC 1.00, 2.00 and 3.00 (returned), plus one record in another symbol.
207
+ def create_usdc_ladder(other: "USD|2.50")
208
+ ladder = ["USDC|1.00", "USDC|2.00", "USDC|3.00"].map { |amount| Holding.create!(amount:) }
209
+ Holding.create!(amount: other)
210
+ ladder
211
+ end
212
+
213
+ def create_fee_ladder
214
+ [0.25, 0.50, 0.75].map { |fee| Holding.create!(fee:) }
215
+ end
216
+
217
+ def changed_holding
218
+ holding = Holding.create!(amount: Amount.new("1.0", :USDC))
219
+ holding.amount = "USDC|2.5"
220
+ holding
221
+ end
222
+
223
+ def saved_holding
224
+ changed_holding.tap(&:save!)
225
+ end
226
+
227
+ def grouped_sums
228
+ Holding.create!(amount: Amount.new("1.5", :USDC))
229
+ Holding.create!(amount: Amount.new("2.0", :USDC))
230
+ Holding.create!(amount: Amount.new("3.0", :USD))
231
+
232
+ Holding.group(:amount_symbol).sum(:amount_atomic)
233
+ end
234
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../test_helper"
4
+ require_relative "../../support/active_record_test_support"
5
+
6
+ class AmountActiveRecordMigrationMethodsTest < Minitest::Test
7
+ include ActiveRecordTestSetup
8
+
9
+ def test_creates_atomic_column_for_multi_symbol_amount
10
+ assert columns.key?("amount_atomic")
11
+ end
12
+
13
+ def test_creates_symbol_column_for_multi_symbol_amount
14
+ assert columns.key?("amount_symbol")
15
+ end
16
+
17
+ def test_creates_atomic_column_for_fixed_symbol_amount
18
+ assert columns.key?("fee_atomic")
19
+ end
20
+
21
+ def test_creates_no_symbol_column_for_fixed_symbol_amount
22
+ refute columns.key?("fee_symbol")
23
+ end
24
+
25
+ def test_atomic_column_precision_defaults_to_seventy_eight
26
+ assert_equal 78, columns.fetch("amount_atomic").precision
27
+ end
28
+
29
+ def test_symbol_column_limit_is_ten_characters
30
+ assert_equal 10, columns.fetch("amount_symbol").limit
31
+ end
32
+
33
+ def test_atomic_column_uses_given_precision
34
+ assert_equal 40, columns.fetch("reserve_atomic").precision
35
+ end
36
+
37
+ def test_default_sets_symbol_column_default
38
+ assert_equal "USDC", columns.fetch("reserve_symbol").default
39
+ end
40
+
41
+ def test_schema_declares_default_atomic_column_as_scale_zero_decimal
42
+ assert_includes schema_sql, "\"amount_atomic\" decimal(78,0)"
43
+ end
44
+
45
+ def test_schema_declares_atomic_column_with_given_precision_as_scale_zero_decimal
46
+ assert_includes schema_sql, "\"reserve_atomic\" decimal(40,0)"
47
+ end
48
+
49
+ private
50
+
51
+ def columns
52
+ Holding.columns_hash
53
+ end
54
+
55
+ def schema_sql
56
+ ::ActiveRecord::Base.connection.select_value(<<~SQL)
57
+ SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'holdings'
58
+ SQL
59
+ end
60
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../test_helper"
4
+ require_relative "../../support/active_record_test_support"
5
+
6
+ class AmountActiveRecordModelTest < Minitest::Test
7
+ include ActiveRecordTestSetup
8
+
9
+ def test_fixed_symbol_writer_with_wrong_symbol_makes_record_invalid
10
+ refute wrong_symbol_fee_holding.valid?
11
+ end
12
+
13
+ def test_fixed_symbol_writer_with_wrong_symbol_reports_error_on_validation
14
+ holding = wrong_symbol_fee_holding
15
+ holding.valid?
16
+
17
+ assert_includes holding.errors[:fee], "expected SOL, got USDC"
18
+ end
19
+
20
+ def test_multi_symbol_writer_with_raw_numeric_makes_record_invalid
21
+ refute raw_numeric_amount_holding.valid?
22
+ end
23
+
24
+ def test_multi_symbol_writer_with_raw_numeric_reports_error_on_validation
25
+ holding = raw_numeric_amount_holding
26
+ holding.valid?
27
+
28
+ assert_includes holding.errors[:amount], "raw numeric assignment requires a fixed symbol"
29
+ end
30
+
31
+ private
32
+
33
+ def wrong_symbol_fee_holding
34
+ Holding.new.tap { |holding| holding.fee = Amount.new("1", :USDC) }
35
+ end
36
+
37
+ def raw_numeric_amount_holding
38
+ Holding.new.tap { |holding| holding.amount = 5 }
39
+ end
40
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../test_helper"
4
+ require_relative "../../support/active_record_test_support"
5
+
6
+ class AmountActiveRecordTypeTest < Minitest::Test
7
+ include ActiveRecordTestSetup
8
+
9
+ def test_multi_symbol_writer_parses_string_into_atomic_column
10
+ assert_equal 250, holding_with(amount: "USD|2.50").amount_atomic
11
+ end
12
+
13
+ def test_multi_symbol_writer_parses_string_into_symbol_column
14
+ assert_equal "USD", holding_with(amount: "USD|2.50").amount_symbol
15
+ end
16
+
17
+ def test_multi_symbol_writer_loads_atomic_hash
18
+ holding = holding_with(amount: { "atomic" => 42, "symbol" => "USDC" })
19
+
20
+ assert_equal Amount.new(42, :USDC, from: :atomic), holding.amount
21
+ end
22
+
23
+ def test_fixed_symbol_writer_converts_raw_float_into_atomic_column
24
+ assert_equal 1_250_000_000, holding_with(fee: 1.25).fee_atomic
25
+ end
26
+
27
+ def test_fixed_symbol_writer_reads_back_raw_float_as_amount
28
+ assert_equal Amount.new("1.25", :SOL), holding_with(fee: 1.25).fee
29
+ end
30
+
31
+ def test_large_atomic_value_round_trips_through_sqlite
32
+ skip "SQLite stores DECIMAL(78,0) values above 64-bit range imprecisely under ActiveRecord"
33
+
34
+ huge_atomic = 10**30
35
+ holding = Holding.create!(amount: Amount.new(huge_atomic, :SOL, from: :atomic))
36
+
37
+ assert_equal huge_atomic, holding.reload.amount.atomic
38
+ end
39
+
40
+ def test_custom_class_symbol_reads_back_as_registered_class
41
+ assert_instance_of metal_class, metal_record.weight
42
+ end
43
+
44
+ def test_custom_class_symbol_reads_back_atomic_value
45
+ assert_equal 25_000, metal_record.weight.atomic
46
+ end
47
+
48
+ private
49
+
50
+ def holding_with(**attributes)
51
+ holding = Holding.new
52
+ attributes.each { |name, value| holding.public_send("#{name}=", value) }
53
+ holding
54
+ end
55
+
56
+ def metal_class
57
+ @metal_class ||= Class.new(Amount) do
58
+ def self.name
59
+ "MetalAmount"
60
+ end
61
+ end
62
+ end
63
+
64
+ def metal_record
65
+ Amount.register :METAL, decimals: 4, class: metal_class
66
+
67
+ ::ActiveRecord::Schema.define do
68
+ create_table :metal_holdings, force: true do |t|
69
+ t.amount :weight, symbol: :METAL, null: false
70
+ end
71
+ end
72
+
73
+ klass = Class.new(::ActiveRecord::Base) do
74
+ self.table_name = "metal_holdings"
75
+ has_amount :weight, symbol: :METAL
76
+ end
77
+
78
+ klass.create!(weight: metal_class.new("2.5", :METAL)).reload
79
+ end
80
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+ require_relative "../../lib/amount/active_record"
5
+
6
+ class AmountActiveRecordTest < Minitest::Test
7
+ def test_locale_file_is_loaded_once
8
+ Amount::ActiveRecord.install_locale!
9
+
10
+ assert_equal 1, I18n.load_path.count(Amount::ActiveRecord::LOCALE_PATH)
11
+ end
12
+ end