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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ begin
6
+ require "pg"
7
+ rescue LoadError
8
+ # Handled in setup.
9
+ end
10
+
11
+ ENV["RAILS_ENV"] = "test"
12
+
13
+ require_relative "../dummy/config/environment"
14
+ require "active_record/tasks/database_tasks"
15
+
16
+ class PostgreSQLIntegrationTest < Minitest::Test
17
+ DUMMY_ROOT = File.expand_path("../dummy", __dir__)
18
+
19
+ def setup
20
+ skip "pg gem is not installed" unless defined?(PG)
21
+ skip "set AMOUNTS_POSTGRES_URL to run PostgreSQL integration tests" unless ENV["AMOUNTS_POSTGRES_URL"]
22
+
23
+ AmountTestSupport.register_active_record_types!
24
+ configure_database_tasks
25
+ recreate_schema!
26
+ # With a URL set, an unreachable database is an error, not a skip, so a
27
+ # broken CI service can't pass silently.
28
+ Holding.delete_all
29
+ end
30
+
31
+ def teardown
32
+ Amount.registry.clear!
33
+ end
34
+
35
+ def test_postgresql_round_trips_large_atomic_values_exactly
36
+ huge_atomic = 10**30
37
+ holding = Holding.create!(amount: Amount.new(huge_atomic, :SOL, from: :atomic))
38
+
39
+ assert_equal huge_atomic, holding.reload.amount.atomic
40
+ end
41
+
42
+ def test_postgresql_uses_default_numeric_precision_for_amount_columns
43
+ assert_equal 78, Holding.columns_hash.fetch("amount_atomic").precision
44
+ end
45
+
46
+ # Rails reports `scale` as nil for any zero-scale decimal, so check the
47
+ # column type PostgreSQL actually created.
48
+ def test_postgresql_uses_zero_scale_for_amount_columns
49
+ assert_equal "numeric(78,0)", Holding.columns_hash.fetch("amount_atomic").sql_type
50
+ end
51
+
52
+ def test_postgresql_uses_given_numeric_precision_for_amount_columns
53
+ assert_equal 40, Holding.columns_hash.fetch("reserve_atomic").precision
54
+ end
55
+
56
+ def test_postgresql_uses_zero_scale_for_amount_columns_with_given_precision
57
+ assert_equal "numeric(40,0)", Holding.columns_hash.fetch("reserve_atomic").sql_type
58
+ end
59
+
60
+ def test_postgresql_where_query_works_with_amount_columns
61
+ usdc = create_usdc_and_usd_holdings
62
+
63
+ assert_equal [usdc.id], Holding.where_amount("USDC|1.50").pluck(:id)
64
+ end
65
+
66
+ def test_postgresql_group_query_works_with_amount_columns
67
+ create_usdc_and_usd_holdings
68
+
69
+ assert_equal({ "USDC" => 1_500_000, "USD" => 300 }, Holding.group(:amount_symbol).sum(:amount_atomic))
70
+ end
71
+
72
+ private
73
+
74
+ # The USDC holding; a USD holding is created alongside.
75
+ def create_usdc_and_usd_holdings
76
+ usdc = Holding.create!(amount: "USDC|1.50")
77
+ Holding.create!(amount: "USD|3.00")
78
+ usdc
79
+ end
80
+
81
+ def configure_database_tasks
82
+ ActiveRecord::Tasks::DatabaseTasks.database_configuration = Rails.application.config.database_configuration
83
+ ActiveRecord::Tasks::DatabaseTasks.db_dir = File.join(DUMMY_ROOT, "db")
84
+ ActiveRecord::Tasks::DatabaseTasks.env = "test"
85
+ ActiveRecord::Tasks::DatabaseTasks.root = DUMMY_ROOT
86
+ end
87
+
88
+ def recreate_schema!
89
+ ActiveRecord::Base.establish_connection(:test)
90
+ ActiveRecord::Migration.verbose = false
91
+ load File.join(DUMMY_ROOT, "db/schema.rb")
92
+ Holding.reset_column_information
93
+ end
94
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "sqlite3"
5
+ require_relative "amount_test_support"
6
+ require_relative "../../lib/amount/active_record"
7
+
8
+ # In-memory SQLite schema and models shared by the ActiveRecord adapter tests.
9
+ # Loaded once per process however many test files require it.
10
+
11
+ AmountTestSupport.register_active_record_types!
12
+
13
+ ::ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
14
+
15
+ ::ActiveRecord::Schema.define do
16
+ create_table :holdings, force: true do |t|
17
+ t.amount :amount, null: true
18
+ t.amount :fee, symbol: :SOL, null: true
19
+ t.amount :reserve, precision: 40, default: "USDC|1.25", null: false
20
+ end
21
+ end
22
+
23
+ class Holding < ::ActiveRecord::Base
24
+ has_amount :amount
25
+ has_amount :fee, symbol: :SOL
26
+ has_amount :reserve
27
+ end
28
+
29
+ class ValidatedHolding < ::ActiveRecord::Base
30
+ self.table_name = "holdings"
31
+
32
+ has_amount :amount
33
+ has_amount :fee, symbol: :SOL
34
+ has_amount :reserve
35
+
36
+ validates :amount, amount: {
37
+ symbol: :USDC,
38
+ greater_than: "USDC|0",
39
+ less_than_or_equal_to: "USDC|1000"
40
+ }
41
+ validates :fee, amount: {
42
+ symbol: :SOL,
43
+ greater_than: 0,
44
+ less_than: 10,
45
+ allow_nil: true
46
+ }
47
+ validates :reserve, amount: {
48
+ greater_than_or_equal_to: "USDC|1.25"
49
+ }
50
+ end
51
+
52
+ # Empty tables and the ActiveRecord test types before each test; an empty
53
+ # registry after it.
54
+ module ActiveRecordTestSetup
55
+ def setup
56
+ Holding.delete_all
57
+ ValidatedHolding.delete_all
58
+
59
+ AmountTestSupport.register_active_record_types!
60
+ end
61
+
62
+ def teardown
63
+ Amount.registry.clear!
64
+ end
65
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+
5
+ # Helpers for tests that assert on an error's message, so the raise and the
6
+ # message can be checked in separate single-assertion tests.
7
+ module ErrorHelpers
8
+ # The error the block raises. Not an assertion: the test still asserts on
9
+ # the result. Fails the test when nothing is raised.
10
+ #
11
+ # @param klass [Class<Exception>]
12
+ # @return [Exception]
13
+ def error_from(klass = StandardError)
14
+ yield
15
+ rescue klass => e
16
+ e
17
+ else
18
+ raise Minitest::Assertion, "expected #{klass} to be raised, nothing was raised"
19
+ end
20
+ end
21
+
22
+ Minitest::Test.include(ErrorHelpers)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+
5
+ # Default types plus a fresh I18n backend with en, de and fr number formats,
6
+ # restored after each test.
7
+ module LocaleTestSetup
8
+ def setup
9
+ AmountTestSupport.register_default_types!
10
+ @previous_locale = I18n.locale
11
+ @previous_backend = I18n.backend
12
+ I18n.backend = I18n::Backend::Simple.new
13
+ I18n.available_locales = %i[en de fr xx]
14
+ I18n.backend.store_translations(:en, number: { format: { delimiter: ",", separator: "." } })
15
+ I18n.backend.store_translations(:de, number: { format: { delimiter: ".", separator: "," } })
16
+ I18n.backend.store_translations(:fr, number: { format: { delimiter: " ", separator: "," } })
17
+ Amount.localize = false
18
+ end
19
+
20
+ def teardown
21
+ Amount.localize = false
22
+ I18n.backend = @previous_backend
23
+ I18n.locale = @previous_locale
24
+ Amount.registry.clear!
25
+ end
26
+
27
+ private
28
+
29
+ # Runs the block as if the i18n gem were not loaded.
30
+ def without_i18n
31
+ i18n = Object.send(:remove_const, :I18n)
32
+ yield
33
+ ensure
34
+ Object.const_set(:I18n, i18n)
35
+ end
36
+ end
data/test/test_helper.rb CHANGED
@@ -2,3 +2,4 @@
2
2
 
3
3
  require "minitest/autorun"
4
4
  require_relative "support/amount_test_support"
5
+ require_relative "support/error_helpers"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: amounts
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seb Scholl
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-02 00:00:00.000000000 Z
11
+ date: 2026-11-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -47,24 +47,34 @@ files:
47
47
  - lib/amount/active_record.rb
48
48
  - lib/amount/active_record/amount_validator.rb
49
49
  - lib/amount/active_record/attribute_definition.rb
50
+ - lib/amount/active_record/locale/en.yml
50
51
  - lib/amount/active_record/migration_methods.rb
51
52
  - lib/amount/active_record/model.rb
52
53
  - lib/amount/active_record/railtie.rb
53
54
  - lib/amount/active_record/rspec.rb
54
55
  - lib/amount/active_record/rspec/matchers.rb
55
56
  - lib/amount/active_record/type.rb
56
- - lib/amount/allocation.rb
57
- - lib/amount/arithmetic.rb
58
- - lib/amount/comparison.rb
59
- - lib/amount/conversion.rb
57
+ - lib/amount/compact.rb
58
+ - lib/amount/concerns/allocatable.rb
59
+ - lib/amount/concerns/calculable.rb
60
+ - lib/amount/concerns/coercible.rb
61
+ - lib/amount/concerns/constructible.rb
62
+ - lib/amount/concerns/convertible.rb
63
+ - lib/amount/concerns/formattable.rb
64
+ - lib/amount/concerns/localizable.rb
65
+ - lib/amount/concerns/orderable.rb
66
+ - lib/amount/concerns/parsable.rb
67
+ - lib/amount/concerns/registrable.rb
68
+ - lib/amount/concerns/serializable.rb
60
69
  - lib/amount/display.rb
70
+ - lib/amount/locale.rb
61
71
  - lib/amount/parser.rb
62
72
  - lib/amount/registry.rb
63
73
  - lib/amount/registry/generated_constructors.rb
64
74
  - lib/amount/rspec.rb
65
75
  - lib/amount/rspec/matchers.rb
66
76
  - lib/amount/rspec/support.rb
67
- - lib/amount/serialization.rb
77
+ - lib/amount/ui_parser.rb
68
78
  - lib/amount/version.rb
69
79
  - lib/amounts.rb
70
80
  - lib/generators/amount/active_record/registry_generator.rb
@@ -73,6 +83,31 @@ files:
73
83
  - lib/generators/amount/active_record/templates/presets/fiat.fragment
74
84
  - lib/generators/amount/active_record/templates/presets/metals.fragment
75
85
  - lib/generators/amount/active_record/templates/registry.rb.tt
86
+ - test/amount/active_record/amount_validator_test.rb
87
+ - test/amount/active_record/attribute_definition_test.rb
88
+ - test/amount/active_record/migration_methods_test.rb
89
+ - test/amount/active_record/model_test.rb
90
+ - test/amount/active_record/type_test.rb
91
+ - test/amount/active_record_test.rb
92
+ - test/amount/compact_test.rb
93
+ - test/amount/concerns/allocatable_test.rb
94
+ - test/amount/concerns/calculable_test.rb
95
+ - test/amount/concerns/coercible_test.rb
96
+ - test/amount/concerns/constructible_test.rb
97
+ - test/amount/concerns/convertible_test.rb
98
+ - test/amount/concerns/formattable_test.rb
99
+ - test/amount/concerns/localizable_test.rb
100
+ - test/amount/concerns/orderable_test.rb
101
+ - test/amount/concerns/parsable_test.rb
102
+ - test/amount/concerns/registrable_test.rb
103
+ - test/amount/concerns/serializable_test.rb
104
+ - test/amount/display_test.rb
105
+ - test/amount/locale_test.rb
106
+ - test/amount/parser_test.rb
107
+ - test/amount/registry/generated_constructors_test.rb
108
+ - test/amount/registry_test.rb
109
+ - test/amount/ui_parser_test.rb
110
+ - test/amount_test.rb
76
111
  - test/dummy/app/models/holding.rb
77
112
  - test/dummy/bin/rails
78
113
  - test/dummy/config/application.rb
@@ -80,12 +115,13 @@ files:
80
115
  - test/dummy/config/environment.rb
81
116
  - test/dummy/db/schema.rb
82
117
  - test/dummy/log/test.log
83
- - test/postgresql_integration_test.rb
118
+ - test/generators/amount/active_record/registry_generator_test.rb
119
+ - test/integration/postgresql_test.rb
120
+ - test/support/active_record_test_support.rb
84
121
  - test/support/amount_test_support.rb
85
- - test/test_active_record.rb
86
- - test/test_amount.rb
122
+ - test/support/error_helpers.rb
123
+ - test/support/locale_test_setup.rb
87
124
  - test/test_helper.rb
88
- - test/test_registry_generator.rb
89
125
  homepage: https://github.com/zarpay/amounts
90
126
  licenses:
91
127
  - MIT
@@ -1,71 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "test_helper"
4
-
5
- begin
6
- require "pg"
7
- rescue LoadError
8
- # Handled in setup.
9
- end
10
-
11
- ENV["RAILS_ENV"] = "test"
12
-
13
- require_relative "dummy/config/environment"
14
- require "active_record/tasks/database_tasks"
15
-
16
- class PostgreSQLIntegrationTest < Minitest::Test
17
- def setup
18
- skip "pg gem is not installed" unless defined?(PG)
19
- skip "set AMOUNTS_POSTGRES_URL to run PostgreSQL integration tests" unless ENV["AMOUNTS_POSTGRES_URL"]
20
-
21
- AmountTestSupport.register_active_record_types!
22
- configure_database_tasks
23
- recreate_schema!
24
- Holding.delete_all
25
- rescue PG::Error, ActiveRecord::ConnectionNotEstablished => error
26
- skip "PostgreSQL is unavailable: #{error.message}"
27
- end
28
-
29
- def teardown
30
- Amount.registry.clear!
31
- end
32
-
33
- def test_postgresql_round_trips_large_atomic_values_exactly
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_postgresql_uses_numeric_precision_for_amount_columns
41
- columns = Holding.columns_hash
42
-
43
- assert_equal 78, columns.fetch("amount_atomic").precision
44
- assert_equal 0, columns.fetch("amount_atomic").scale
45
- assert_equal 40, columns.fetch("reserve_atomic").precision
46
- assert_equal 0, columns.fetch("reserve_atomic").scale
47
- end
48
-
49
- def test_postgresql_where_and_group_queries_work_with_amount_columns
50
- usdc = Holding.create!(amount: "USDC|1.50")
51
- Holding.create!(amount: "USD|3.00")
52
-
53
- assert_equal [usdc.id], Holding.where_amount("USDC|1.50").pluck(:id)
54
- assert_equal({ "USDC" => 1_500_000, "USD" => 300 }, Holding.group(:amount_symbol).sum(:amount_atomic))
55
- end
56
-
57
- private
58
-
59
- def configure_database_tasks
60
- ActiveRecord::Tasks::DatabaseTasks.database_configuration = Rails.application.config.database_configuration
61
- ActiveRecord::Tasks::DatabaseTasks.db_dir = File.expand_path("dummy/db", __dir__)
62
- ActiveRecord::Tasks::DatabaseTasks.env = "test"
63
- ActiveRecord::Tasks::DatabaseTasks.root = File.expand_path("dummy", __dir__)
64
- end
65
-
66
- def recreate_schema!
67
- ActiveRecord::Base.establish_connection(:test)
68
- load File.expand_path("dummy/db/schema.rb", __dir__)
69
- Holding.reset_column_information
70
- end
71
- end