amounts 0.0.8 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +37 -0
  3. data/CHANGELOG.md +113 -0
  4. data/Gemfile +4 -0
  5. data/README.md +215 -1
  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.rb +12 -0
  12. data/lib/amount/compact.rb +137 -0
  13. data/lib/amount/{allocation.rb → concerns/allocatable.rb} +1 -1
  14. data/lib/amount/{arithmetic.rb → concerns/calculable.rb} +1 -1
  15. data/lib/amount/concerns/coercible.rb +65 -0
  16. data/lib/amount/concerns/constructible.rb +92 -0
  17. data/lib/amount/{conversion.rb → concerns/convertible.rb} +1 -1
  18. data/lib/amount/concerns/formattable.rb +112 -0
  19. data/lib/amount/concerns/localizable.rb +39 -0
  20. data/lib/amount/{comparison.rb → concerns/orderable.rb} +2 -2
  21. data/lib/amount/concerns/parsable.rb +58 -0
  22. data/lib/amount/concerns/registrable.rb +80 -0
  23. data/lib/amount/{serialization.rb → concerns/serializable.rb} +3 -3
  24. data/lib/amount/display.rb +81 -46
  25. data/lib/amount/locale.rb +102 -0
  26. data/lib/amount/registry.rb +69 -7
  27. data/lib/amount/ui_parser.rb +109 -0
  28. data/lib/amount/version.rb +1 -1
  29. data/lib/amount.rb +55 -199
  30. data/test/amount/active_record/amount_validator_test.rb +201 -0
  31. data/test/amount/active_record/attribute_definition_test.rb +234 -0
  32. data/test/amount/active_record/migration_methods_test.rb +60 -0
  33. data/test/amount/active_record/model_test.rb +40 -0
  34. data/test/amount/active_record/type_test.rb +80 -0
  35. data/test/amount/active_record_test.rb +12 -0
  36. data/test/amount/compact_test.rb +371 -0
  37. data/test/amount/concerns/allocatable_test.rb +166 -0
  38. data/test/amount/concerns/calculable_test.rb +152 -0
  39. data/test/amount/concerns/coercible_test.rb +49 -0
  40. data/test/amount/concerns/constructible_test.rb +113 -0
  41. data/test/amount/concerns/convertible_test.rb +54 -0
  42. data/test/amount/concerns/formattable_test.rb +336 -0
  43. data/test/amount/concerns/localizable_test.rb +59 -0
  44. data/test/amount/concerns/orderable_test.rb +83 -0
  45. data/test/amount/concerns/parsable_test.rb +98 -0
  46. data/test/amount/concerns/registrable_test.rb +67 -0
  47. data/test/amount/concerns/serializable_test.rb +98 -0
  48. data/test/amount/display_test.rb +181 -0
  49. data/test/amount/locale_test.rb +590 -0
  50. data/test/amount/parser_test.rb +29 -0
  51. data/test/amount/registry/generated_constructors_test.rb +94 -0
  52. data/test/amount/registry_test.rb +149 -0
  53. data/test/amount/ui_parser_test.rb +284 -0
  54. data/test/amount_test.rb +55 -0
  55. data/test/dummy/config/application.rb +3 -0
  56. data/test/generators/amount/active_record/registry_generator_test.rb +116 -0
  57. data/test/integration/postgresql_test.rb +94 -0
  58. data/test/support/active_record_test_support.rb +65 -0
  59. data/test/support/error_helpers.rb +22 -0
  60. data/test/support/locale_test_setup.rb +36 -0
  61. data/test/test_helper.rb +1 -0
  62. metadata +47 -11
  63. data/test/postgresql_integration_test.rb +0 -71
  64. data/test/test_active_record.rb +0 -375
  65. data/test/test_amount.rb +0 -675
  66. data/test/test_registry_generator.rb +0 -104
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+ require_relative "../../test_helper"
5
+
6
+ class AmountParsableTest < Minitest::Test
7
+ class GoldAmount < Amount
8
+ def purity = "24k"
9
+ end
10
+
11
+ def setup
12
+ AmountTestSupport.register_default_types!
13
+ @previous_locale = I18n.locale
14
+ @previous_backend = I18n.backend
15
+ I18n.backend = I18n::Backend::Simple.new
16
+ I18n.available_locales = %i[en de fr]
17
+ I18n.backend.store_translations(:en, number: { format: { delimiter: ",", separator: "." } })
18
+ I18n.backend.store_translations(:de, number: { format: { delimiter: ".", separator: "," } })
19
+ I18n.backend.store_translations(:fr, number: { format: { delimiter: " ", separator: "," } })
20
+ Amount.localize = false
21
+ end
22
+
23
+ def teardown
24
+ Amount.localize = false
25
+ I18n.backend = @previous_backend
26
+ I18n.locale = @previous_locale
27
+ Amount.registry.clear!
28
+ end
29
+
30
+ def test_parse_reads_atomic_value
31
+ assert_equal 1_500_000, Amount.parse("USDC|1.5").atomic
32
+ end
33
+
34
+ def test_parse_roundtrips_to_s
35
+ assert_equal "USDC|1.50", Amount.parse("USDC|1.5").to_s
36
+ end
37
+
38
+ def test_wire_parse_unchanged
39
+ assert_equal 150_050, Amount.parse("USD|1500.50").atomic
40
+ end
41
+
42
+ def test_parse_dispatches_to_registered_subclass
43
+ use_gold_amount_registry!
44
+
45
+ assert_instance_of GoldAmount, Amount.parse("GOLD|1.0")
46
+ end
47
+
48
+ def test_parse_ui_returns_amount_for_registered_class
49
+ assert_instance_of Amount, Amount.parse_ui("1.50", :USD)
50
+ end
51
+
52
+ def test_parse_ui_unregistered_symbol_raises_unknown_type_from_new
53
+ assert_raises(Amount::Registry::UnknownType) { Amount.new("1", :NOPE) }
54
+ end
55
+
56
+ def test_parse_ui_unregistered_symbol_raises_unknown_type
57
+ assert_raises(Amount::Registry::UnknownType) { Amount.parse_ui("1", :NOPE) }
58
+ end
59
+
60
+ def test_parse_ui_unregistered_symbol_message_matches_new
61
+ new_error = error_from(Amount::Registry::UnknownType) { Amount.new("1", :NOPE) }
62
+ error = error_from(Amount::Registry::UnknownType) { Amount.parse_ui("1", :NOPE) }
63
+
64
+ assert_equal new_error.message, error.message
65
+ end
66
+
67
+ def test_parse_ui_returns_registered_subclass
68
+ use_gold_amount_registry!
69
+
70
+ assert_instance_of GoldAmount, Amount.parse_ui("1.500,5 oz", :GOLD, locale: :de)
71
+ end
72
+
73
+ def test_parse_ui_into_registered_subclass_reads_atomic_value
74
+ use_gold_amount_registry!
75
+
76
+ assert_equal 15_005_000, Amount.parse_ui("1.500,5 oz", :GOLD, locale: :de).atomic
77
+ end
78
+
79
+ def test_parse_ui_into_registered_subclass_has_subclass_behavior
80
+ use_gold_amount_registry!
81
+
82
+ assert_equal "24k", Amount.parse_ui("1.500,5 oz", :GOLD, locale: :de).purity
83
+ end
84
+
85
+ def test_parse_ui_into_registered_subclass_rejects_too_many_decimals
86
+ use_gold_amount_registry!
87
+
88
+ assert_raises(Amount::InvalidInput) { Amount.parse_ui("1,00001", :GOLD, locale: :de) }
89
+ end
90
+
91
+ private
92
+
93
+ # Replaces the default types with GOLD registered to {GoldAmount}.
94
+ def use_gold_amount_registry!
95
+ Amount.registry.clear!
96
+ Amount.register :GOLD, decimals: 4, display_symbol: "oz", class: GoldAmount
97
+ end
98
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../test_helper"
4
+
5
+ class AmountRegistrableTest < 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_registry_returns_the_same_object_on_repeated_calls
15
+ assert_same Amount.registry, Amount.registry
16
+ end
17
+
18
+ def test_subclass_shares_amount_registry
19
+ assert_same Amount.registry, Class.new(Amount).registry
20
+ end
21
+
22
+ def test_register_adds_a_type
23
+ Amount.register :PTS, decimals: 0
24
+
25
+ assert_equal :PTS, Amount.registry.lookup(:PTS).symbol
26
+ end
27
+
28
+ def test_register_default_rate_stores_the_rate
29
+ Amount.register_default_rate :USD, :USDC, "1"
30
+
31
+ assert_equal BigDecimal("1"), Amount.registry.default_rate(:USD, :USDC)
32
+ end
33
+
34
+ def test_with_registry_activates_the_given_registry_inside_the_block
35
+ temporary = Amount::Registry.new
36
+
37
+ assert_same temporary, Amount.with_registry(temporary) { Amount.registry }
38
+ end
39
+
40
+ def test_with_registry_restores_the_original_registry_afterwards
41
+ original = Amount.registry
42
+ Amount.with_registry(Amount::Registry.new) { nil }
43
+
44
+ assert_same original, Amount.registry
45
+ end
46
+
47
+ def test_with_registry_restores_the_original_registry_when_the_block_raises
48
+ original = Amount.registry
49
+ error_from(RuntimeError) { Amount.with_registry(Amount::Registry.new) { raise "boom" } }
50
+
51
+ assert_same original, Amount.registry
52
+ end
53
+
54
+ def test_with_registry_returns_the_block_value
55
+ assert_equal :result, Amount.with_registry(Amount::Registry.new) { :result }
56
+ end
57
+
58
+ def test_with_registry_removes_outgoing_generated_constructors_inside_the_block
59
+ refute Amount.with_registry(Amount::Registry.new) { Amount.respond_to?(:of_usdc) }
60
+ end
61
+
62
+ def test_with_registry_restores_outgoing_generated_constructors_afterwards
63
+ Amount.with_registry(Amount::Registry.new) { nil }
64
+
65
+ assert Amount.respond_to?(:of_usdc)
66
+ end
67
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../../test_helper"
5
+
6
+ class AmountSerializableTest < Minitest::Test
7
+ class GoldAmount < Amount
8
+ def purity_estimate
9
+ "24k"
10
+ end
11
+ end
12
+
13
+ def setup
14
+ AmountTestSupport.register_default_types!
15
+ end
16
+
17
+ def teardown
18
+ Amount.registry.clear!
19
+ end
20
+
21
+ def test_to_h_and_load_round_trip_for_large_negative_amount
22
+ amount = Amount.new(-(10**30), :USDC, from: :atomic)
23
+
24
+ assert_equal amount, Amount.load(amount.to_h)
25
+ end
26
+
27
+ def test_to_h_returns_versioned_string_safe_payload
28
+ assert_equal(
29
+ { v: 1, atomic: "1500000", symbol: "USDC" },
30
+ Amount.new("1.5", :USDC).to_h
31
+ )
32
+ end
33
+
34
+ def test_load_accepts_legacy_unversioned_payload
35
+ amount = Amount.load(atomic: 1_500_000, symbol: :USDC)
36
+
37
+ assert_equal Amount.new("1.5", :USDC), amount
38
+ end
39
+
40
+ def test_load_without_atomic_raises_invalid_input
41
+ assert_raises(Amount::InvalidInput) { Amount.load(v: 1, symbol: "USDC") }
42
+ end
43
+
44
+ def test_load_without_atomic_names_the_missing_key
45
+ assert_match(/missing key: atomic/, error_from(Amount::InvalidInput) { Amount.load(v: 1, symbol: "USDC") }.message)
46
+ end
47
+
48
+ def test_load_empty_payload_raises_invalid_input
49
+ assert_raises(Amount::InvalidInput) { Amount.load({}) }
50
+ end
51
+
52
+ def test_load_empty_payload_reports_missing_key
53
+ assert_match(/missing key/, error_from(Amount::InvalidInput) { Amount.load({}) }.message)
54
+ end
55
+
56
+ def test_as_json_returns_compact_string
57
+ assert_equal "USDC|1.50", Amount.new("1.5", :USDC).as_json
58
+ end
59
+
60
+ def test_to_json_returns_quoted_compact_string
61
+ assert_equal '"USDC|1.50"', Amount.new("1.5", :USDC).to_json
62
+ end
63
+
64
+ def test_as_json_works_when_nested_in_hash
65
+ hash = { amount: Amount.new("1.5", :USDC) }
66
+
67
+ assert_equal({ "amount" => "USDC|1.50" }, JSON.parse(hash.to_json))
68
+ end
69
+
70
+ def test_load_rejects_unknown_serialization_version
71
+ assert_raises(Amount::InvalidInput) do
72
+ Amount.load(v: 2, atomic: "1500000", symbol: "USDC")
73
+ end
74
+ end
75
+
76
+ def test_load_dispatches_to_registered_subclass
77
+ assert_instance_of GoldAmount, restored_gold
78
+ end
79
+
80
+ def test_load_restores_subclass_behavior
81
+ assert_equal "24k", restored_gold.purity_estimate
82
+ end
83
+
84
+ private
85
+
86
+ def restored_gold
87
+ register_gold_subclass!
88
+ Amount.load(GoldAmount.new("1", :GOLD).to_h)
89
+ end
90
+
91
+ # Replaces the default registry with GOLD bound to GoldAmount.
92
+ def register_gold_subclass!
93
+ Amount.registry.clear!
94
+ Amount.register :GOLD, decimals: 8, display_symbol: "oz t",
95
+ display_position: :suffix, ui_decimals: 4,
96
+ class: GoldAmount
97
+ end
98
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require_relative "../test_helper"
5
+
6
+ class AmountDisplayTest < Minitest::Test
7
+ def setup
8
+ AmountTestSupport.register_default_types!
9
+ end
10
+
11
+ def teardown
12
+ Amount.registry.clear!
13
+ end
14
+
15
+ # name => [expected, value, symbol]
16
+ {
17
+ "usdc" => ["1.500000", "1.5", :USDC],
18
+ "sol" => ["1.500000000", "1.5", :SOL],
19
+ "logs" => ["1", 1, :LOGS]
20
+ }.each do |name, (expected, value, symbol)|
21
+ define_method("test_formatted_respects_storage_decimals_for_#{name}") do
22
+ assert_equal expected, Amount.new(value, symbol).formatted
23
+ end
24
+ end
25
+
26
+ # name => [expected, value, symbol]
27
+ {
28
+ "usdc" => ["$1.50", "1.5", :USDC],
29
+ "usdc_rounded_down" => ["$1.56", "1.567", :USDC],
30
+ "sol" => ["1.5000 SOL", "1.5", :SOL],
31
+ "logs" => ["1 logs", 1, :LOGS]
32
+ }.each do |name, (expected, value, symbol)|
33
+ define_method("test_ui_respects_ui_decimals_and_position_for_#{name}") do
34
+ assert_equal expected, Amount.new(value, symbol).ui
35
+ end
36
+ end
37
+
38
+ def test_ui_ceil_direction
39
+ assert_equal "$1.57", Amount.new("1.561", :USDC).ui(direction: :ceil)
40
+ end
41
+
42
+ # name => [expected, value, symbol]
43
+ {
44
+ "usdc" => ["1.50", "1.5", :USDC],
45
+ "sol" => ["1.5000", "1.5", :SOL],
46
+ "logs" => ["1", 1, :LOGS]
47
+ }.each do |name, (expected, value, symbol)|
48
+ define_method("test_ui_decorated_false_omits_the_display_symbol_for_#{name}") do
49
+ assert_equal expected, Amount.new(value, symbol).ui(decorated: false)
50
+ end
51
+ end
52
+
53
+ def test_ui_decorated_false_with_ceil_direction
54
+ assert_equal "1.57", Amount.new("1.561", :USDC).ui(direction: :ceil, decorated: false)
55
+ end
56
+
57
+ # name => [expected, ui options]
58
+ {
59
+ "default_unit" => ["1.5000 oz t", {}],
60
+ "gram" => ["46.65 g", { unit: :gram }],
61
+ "kg" => ["0.04665 kg", { unit: :kg }]
62
+ }.each do |name, (expected, options)|
63
+ define_method("test_display_units_scale_output_in_#{name}") do
64
+ assert_equal expected, Amount.new("1.5", :GOLD).ui(**options)
65
+ end
66
+ end
67
+
68
+ # name => [expected, ui options]
69
+ {
70
+ "default_unit" => ["1.5000", { decorated: false }],
71
+ "gram" => ["46.65", { unit: :gram, decorated: false }],
72
+ "kg" => ["0.04665", { unit: :kg, decorated: false }]
73
+ }.each do |name, (expected, options)|
74
+ define_method("test_display_units_with_decorated_false_in_#{name}") do
75
+ assert_equal expected, Amount.new("1.5", :GOLD).ui(**options)
76
+ end
77
+ end
78
+
79
+ def test_in_unit_returns_raw_bigdecimal
80
+ gold = Amount.new("1.0", :GOLD)
81
+
82
+ assert_in_delta 31.1035, gold.in_unit(:gram).to_f, 0.0001
83
+ end
84
+
85
+ def test_unknown_display_unit_raises
86
+ assert_raises(Amount::Registry::InvalidDisplayUnit) do
87
+ Amount.new("1", :GOLD).ui(unit: :pound)
88
+ end
89
+ end
90
+
91
+ # name => [expected, value, from]
92
+ {
93
+ "fraction" => ["2.5 TZ", "2.5", nil],
94
+ "whole" => ["1 TZ", "1.0", nil],
95
+ "zero" => ["0 TZ", "0.0", nil],
96
+ "no_trailing_zeros" => ["1.123 TZ", "1.123", nil],
97
+ "small_atomic" => ["0.00005 TZ", 50_000, :atomic]
98
+ }.each do |name, (expected, value, from)|
99
+ define_method("test_trim_zeros_strips_trailing_zeros_for_#{name}") do
100
+ register_trim_zeros_type!
101
+ amount = from ? Amount.new(value, :TZ, from:) : Amount.new(value, :TZ)
102
+
103
+ assert_equal expected, amount.ui
104
+ end
105
+ end
106
+
107
+ # name => [expected, value]
108
+ {
109
+ "fraction" => ["$1.50", "1.5"],
110
+ "whole" => ["$1.00", "1.0"]
111
+ }.each do |name, (expected, value)|
112
+ define_method("test_trim_zeros_false_preserves_trailing_zeros_for_#{name}") do
113
+ assert_equal expected, Amount.new(value, :USDC).ui
114
+ end
115
+ end
116
+
117
+ # name => [expected, value]
118
+ {
119
+ "fraction" => ["2.5", "2.5"],
120
+ "whole" => ["1", "1.0"]
121
+ }.each do |name, (expected, value)|
122
+ define_method("test_trim_zeros_with_decorated_false_for_#{name}") do
123
+ register_trim_zeros_type!
124
+
125
+ assert_equal expected, Amount.new(value, :TZ).ui(decorated: false)
126
+ end
127
+ end
128
+
129
+ def test_trim_zeros_with_display_units
130
+ Amount.register :TZG, decimals: 8, display_symbol: "oz t", display_position: :suffix,
131
+ trim_zeros: true,
132
+ display_units: { gram: { scale: "31.1035", symbol: "g", ui_decimals: 4 } }
133
+
134
+ assert_equal "31.1035 g", Amount.new("1.0", :TZG).ui(unit: :gram)
135
+ end
136
+
137
+ def test_trim_zeros_display_unit_override
138
+ Amount.register :TZO, decimals: 6, display_symbol: "T", display_position: :suffix,
139
+ trim_zeros: true,
140
+ display_units: { fixed: { scale: 1, symbol: "F", ui_decimals: 2, trim_zeros: false } }
141
+
142
+ assert_equal "1.50 F", Amount.new("1.5", :TZO).ui(unit: :fixed)
143
+ end
144
+
145
+ def test_trim_zeros_call_site_true_strips_zeros
146
+ assert_equal "1.5 SOL", Amount.new("1.5", :SOL).ui(trim_zeros: true)
147
+ end
148
+
149
+ def test_trim_zeros_call_site_false_keeps_zeros
150
+ assert_equal "1.0000 SOL", Amount.new("1.0", :SOL).ui(trim_zeros: false)
151
+ end
152
+
153
+ def test_trim_zeros_call_site_overrides_registry
154
+ Amount.register :TZR, decimals: 6, display_symbol: "T", display_position: :suffix, trim_zeros: true
155
+
156
+ assert_equal "1.500000 T", Amount.new("1.5", :TZR).ui(trim_zeros: false)
157
+ end
158
+
159
+ def test_in_unit_with_rational_scale
160
+ assert_equal BigDecimal("0.5"), rational_scale_metal.in_unit(:half)
161
+ end
162
+
163
+ def test_ui_with_rational_scale
164
+ assert_match(/\A0\.50/, rational_scale_metal.ui(unit: :half))
165
+ end
166
+
167
+ private
168
+
169
+ def register_trim_zeros_type!
170
+ Amount.register :TZ, decimals: 9, display_symbol: "TZ", display_position: :suffix, trim_zeros: true
171
+ end
172
+
173
+ def rational_scale_metal
174
+ Amount.registry.clear!
175
+ Amount.register :METAL, decimals: 4, display_symbol: "x", display_position: :suffix,
176
+ ui_decimals: 2,
177
+ display_units: { half: { scale: Rational(1, 2), symbol: "h", ui_decimals: 2 } },
178
+ default_display: :half
179
+ Amount.new("1", :METAL)
180
+ end
181
+ end