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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ class Registry
5
+ # Manages registry-driven constructor methods such as `Amount.usdc(...)`.
6
+ class GeneratedConstructors
7
+ METHOD_NAME_PATTERN = /\A[a-z_][a-z0-9_]*\z/
8
+
9
+ def initialize(target: Amount)
10
+ @target = target
11
+ @method_names = []
12
+ end
13
+
14
+ def define_for(entry)
15
+ method_name = method_name_for(entry.symbol)
16
+ return unless method_name
17
+
18
+ raise_collision!(method_name)
19
+
20
+ @target.define_singleton_method(method_name) do |value, **opts|
21
+ resolved_entry = registry.lookup(entry.symbol)
22
+ resolved_entry.amount_class.new(value, entry.symbol, **opts)
23
+ end
24
+
25
+ @method_names << method_name.to_sym
26
+ end
27
+
28
+ def activate(entries)
29
+ entries.each_value { |entry| define_for(entry) }
30
+ end
31
+
32
+ def remove_all
33
+ @method_names.each do |method_name|
34
+ remove_method(method_name)
35
+ end
36
+
37
+ @method_names.clear
38
+ end
39
+
40
+ private
41
+
42
+ def method_name_for(symbol)
43
+ method_name = symbol.to_s.downcase
44
+ return unless method_name.match?(METHOD_NAME_PATTERN)
45
+
46
+ method_name
47
+ end
48
+
49
+ def raise_collision!(method_name)
50
+ return unless @target.respond_to?(method_name)
51
+
52
+ raise AlreadyRegistered, "cannot generate #{@target}.#{method_name} - method already exists"
53
+ end
54
+
55
+ def remove_method(method_name)
56
+ return unless @target.singleton_class.method_defined?(method_name)
57
+
58
+ @target.singleton_class.send(:remove_method, method_name)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "thread"
5
+ require_relative "registry/generated_constructors"
6
+
7
+ class Amount
8
+ # Stores registered amount types and default directional conversion rates.
9
+ #
10
+ # The registry is the configuration surface of the gem. Application code
11
+ # normally uses the shared global instance exposed by {Amount.registry},
12
+ # configures it during boot, and optionally calls {#lock!} when setup is
13
+ # complete.
14
+ #
15
+ # @example Registering types at boot
16
+ # Amount.register :USDC, decimals: 6
17
+ #
18
+ # Amount.register :USD, decimals: 2
19
+ #
20
+ # Amount.register_default_rate :USD, :USDC, "1"
21
+ # Amount.registry.lock!
22
+ class Registry
23
+ class UnknownType < StandardError; end
24
+ class AlreadyRegistered < StandardError; end
25
+ class InvalidDisplayUnit < StandardError; end
26
+ class NoDefaultRate < StandardError; end
27
+ class RegistryLocked < StandardError; end
28
+
29
+ Entry = Struct.new(
30
+ :symbol, :decimals, :display_symbol, :display_position,
31
+ :ui_decimals, :display_units, :default_display, :amount_class,
32
+ keyword_init: true
33
+ )
34
+
35
+ def initialize
36
+ @entries = {}
37
+ @default_rates = {}
38
+ @generated_constructors = GeneratedConstructors.new
39
+ @locked = false
40
+ @lock = Mutex.new
41
+ end
42
+
43
+ # Registers a new fungible type.
44
+ #
45
+ # When the symbol is a valid Ruby method name after downcasing, an
46
+ # ergonomic constructor is also generated on `Amount`, such as
47
+ # `Amount.usdc("1.50")`.
48
+ #
49
+ # @param symbol [Symbol, String] registered type identifier
50
+ # @param decimals [Integer] number of storage decimals
51
+ # @param display_symbol [String] symbol used by UI helpers
52
+ # @param display_position [Symbol] either `:prefix` or `:suffix`
53
+ # @param ui_decimals [Integer] decimals displayed by default UI formatting
54
+ # @param display_units [Hash, nil] optional display-only scaling definitions
55
+ # @param default_display [Symbol, nil] optional default display unit key
56
+ # @param class [Class, nil] optional custom `Amount` subclass
57
+ # @return [void]
58
+ # @raise [AlreadyRegistered] if the symbol is already registered or the
59
+ # generated constructor would collide with an existing method
60
+ # @raise [RegistryLocked] if the registry has been locked
61
+ # @example
62
+ # Amount.register :USDC,
63
+ # decimals: 6,
64
+ # display_symbol: "$",
65
+ # display_position: :prefix,
66
+ # ui_decimals: 2
67
+ def register(symbol, decimals:, display_symbol: symbol.to_s, display_position: :suffix,
68
+ ui_decimals: decimals, display_units: nil, default_display: nil,
69
+ class: nil)
70
+ symbol = symbol.to_sym
71
+
72
+ @lock.synchronize do
73
+ ensure_unlocked!
74
+ raise AlreadyRegistered, "#{symbol} already registered" if @entries.key?(symbol)
75
+
76
+ validate_display_units!(display_units, default_display) if display_units
77
+
78
+ entry = Entry.new(
79
+ symbol:,
80
+ decimals:,
81
+ display_symbol:,
82
+ display_position:,
83
+ ui_decimals:,
84
+ display_units:,
85
+ default_display:,
86
+ amount_class: binding.local_variable_get(:class) || Amount
87
+ )
88
+
89
+ @entries[symbol] = entry
90
+ @generated_constructors.define_for(entry)
91
+ end
92
+ end
93
+
94
+ # @param symbol [Symbol, String]
95
+ # @return [Boolean]
96
+ # @example
97
+ # Amount.registry.registered?(:USDC)
98
+ # # => true
99
+ def registered?(symbol)
100
+ @lock.synchronize { @entries.key?(symbol.to_sym) }
101
+ end
102
+
103
+ # @param symbol [Symbol, String]
104
+ # @return [Entry]
105
+ # @raise [UnknownType]
106
+ # @example
107
+ # Amount.registry.lookup(:USDC).decimals
108
+ # # => 6
109
+ def lookup(symbol)
110
+ @lock.synchronize do
111
+ @entries.fetch(symbol.to_sym) do
112
+ raise UnknownType, "#{symbol} is not registered"
113
+ end
114
+ end
115
+ end
116
+
117
+ # @return [Array<Symbol>]
118
+ # @example
119
+ # Amount.registry.symbols
120
+ # # => [:USDC, :USD]
121
+ def symbols
122
+ @lock.synchronize { @entries.keys }
123
+ end
124
+
125
+ # @return [void]
126
+ # @raise [RegistryLocked] if the registry has been locked
127
+ # @example
128
+ # Amount.registry.clear!
129
+ def clear!
130
+ @lock.synchronize do
131
+ ensure_unlocked!
132
+ @generated_constructors.remove_all
133
+ @entries.clear
134
+ @default_rates.clear
135
+ end
136
+ end
137
+
138
+ # @param from [Symbol, String]
139
+ # @param to [Symbol, String]
140
+ # @param rate [String, Numeric, BigDecimal]
141
+ # @return [void]
142
+ # @raise [RegistryLocked] if the registry has been locked
143
+ # @example
144
+ # Amount.register_default_rate :USD, :USDC, "1"
145
+ def register_default_rate(from, to, rate)
146
+ from = from.to_sym
147
+ to = to.to_sym
148
+
149
+ lookup(from)
150
+ lookup(to)
151
+
152
+ @lock.synchronize do
153
+ ensure_unlocked!
154
+ @default_rates[[from, to]] = BigDecimal(rate.to_s)
155
+ end
156
+ end
157
+
158
+ # @param from [Symbol, String]
159
+ # @param to [Symbol, String]
160
+ # @return [BigDecimal]
161
+ # @raise [NoDefaultRate]
162
+ # @example
163
+ # Amount.registry.default_rate(:USD, :USDC)
164
+ # # => 0.1e1
165
+ def default_rate(from, to)
166
+ @lock.synchronize do
167
+ @default_rates.fetch([from.to_sym, to.to_sym]) do
168
+ raise NoDefaultRate, "no default rate for #{from} -> #{to}; pass rate: explicitly"
169
+ end
170
+ end
171
+ end
172
+
173
+ # @param from [Symbol, String]
174
+ # @param to [Symbol, String]
175
+ # @return [Boolean]
176
+ # @example
177
+ # Amount.registry.default_rate?(:USD, :USDC)
178
+ # # => true
179
+ def default_rate?(from, to)
180
+ @lock.synchronize { @default_rates.key?([from.to_sym, to.to_sym]) }
181
+ end
182
+
183
+ # @return [void]
184
+ # @example Locking the global registry after initialization
185
+ # Amount.registry.lock!
186
+ def lock!
187
+ @lock.synchronize do
188
+ @locked = true
189
+ end
190
+ end
191
+
192
+ # @return [Boolean]
193
+ # @example
194
+ # Amount.registry.locked?
195
+ # # => true
196
+ def locked?
197
+ @lock.synchronize { @locked }
198
+ end
199
+
200
+ # @return [void]
201
+ def activate_generated_methods!
202
+ @lock.synchronize do
203
+ @generated_constructors.activate(@entries)
204
+ end
205
+ end
206
+
207
+ # @return [void]
208
+ def remove_generated_methods!
209
+ @lock.synchronize do
210
+ @generated_constructors.remove_all
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ def ensure_unlocked!
217
+ raise RegistryLocked, "registry is locked" if @locked
218
+ end
219
+
220
+ def validate_display_units!(units, default)
221
+ unless units.is_a?(Hash) && !units.empty?
222
+ raise InvalidDisplayUnit, "display_units must be a non-empty hash"
223
+ end
224
+
225
+ if default && !units.key?(default)
226
+ raise InvalidDisplayUnit, "default_display #{default} not in display_units"
227
+ end
228
+
229
+ units.each do |key, spec|
230
+ unless spec.is_a?(Hash) && spec.key?(:scale)
231
+ raise InvalidDisplayUnit, "display_unit #{key} must have :scale"
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../amount"
4
+ require "rspec/expectations"
5
+ require_relative "rspec_support"
6
+ require_relative "rspec_matchers"
7
+
8
+ # Opt-in RSpec matchers for `Amount`.
9
+ #
10
+ # Applications enable these matchers explicitly in their spec helper:
11
+ #
12
+ # @example
13
+ # require "amount/rspec"
14
+ #
15
+ # expect(Amount.usdc("1.50")).to eq_amount("USDC|1.50")
16
+ # expect(Amount.usdc("1.50")).to be_amount_of(:USDC)
17
+ # expect(Amount.usdc("1.50")).to be_positive_amount
18
+ # expect(Amount.usdc("1.55")).to be_approximately_amount(:USDC, "1.50", within: "0.10")
19
+ Amount::RSpecMatchers.define_amount_equality_matcher(:eq_amount) do |*expected_arguments|
20
+ Amount::RSpecSupport.coerce_amount_arguments(expected_arguments)
21
+ end
22
+
23
+ Amount::RSpecMatchers.define_amount_type_matcher
24
+ Amount::RSpecMatchers.define_amount_predicate_matcher(:be_zero_amount, "a zero Amount", &:zero?)
25
+ Amount::RSpecMatchers.define_amount_predicate_matcher(:be_positive_amount, "a positive Amount", &:positive?)
26
+ Amount::RSpecMatchers.define_amount_predicate_matcher(:be_negative_amount, "a negative Amount", &:negative?)
27
+ Amount::RSpecMatchers.define_approximate_amount_matcher
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ # Internal matcher helpers for the opt-in RSpec integration.
5
+ module RSpecMatchers
6
+ module_function
7
+
8
+ def define_amount_equality_matcher(name, &expected_builder)
9
+ RSpec::Matchers.define name do |*arguments|
10
+ match do |actual|
11
+ @expected = instance_exec(*arguments, &expected_builder)
12
+ actual.is_a?(Amount) && actual == @expected
13
+ end
14
+
15
+ failure_message do |actual|
16
+ return "expected #{actual.inspect} to be an Amount equal to #{@expected.inspect}" unless actual.is_a?(Amount)
17
+
18
+ "expected #{actual.inspect} to equal amount #{@expected.inspect}"
19
+ end
20
+ end
21
+ end
22
+
23
+ def define_amount_predicate_matcher(name, description, &predicate)
24
+ RSpec::Matchers.define name do
25
+ match do |actual|
26
+ actual.is_a?(Amount) && instance_exec(actual, &predicate)
27
+ end
28
+
29
+ failure_message do |actual|
30
+ "expected #{actual.inspect} to be #{description}"
31
+ end
32
+ end
33
+ end
34
+
35
+ def define_amount_type_matcher
36
+ RSpec::Matchers.define :be_amount_of do |expected_symbol|
37
+ match do |actual|
38
+ @expected_symbol = expected_symbol.to_sym
39
+ actual.is_a?(Amount) && actual.symbol == @expected_symbol
40
+ end
41
+
42
+ failure_message do |actual|
43
+ "expected #{actual.inspect} to be an Amount of #{@expected_symbol}"
44
+ end
45
+ end
46
+ end
47
+
48
+ def define_approximate_amount_matcher
49
+ RSpec::Matchers.define :be_approximately_amount do |*expected_arguments, within:|
50
+ match do |actual|
51
+ @expected = Amount::RSpecSupport.coerce_amount_arguments(expected_arguments)
52
+ @within = Amount::RSpecSupport.coerce_delta(@expected, within)
53
+
54
+ actual.is_a?(Amount) &&
55
+ actual.same_type?(@expected) &&
56
+ @within.same_type?(@expected) &&
57
+ (actual - @expected).abs.atomic <= @within.atomic
58
+ end
59
+
60
+ failure_message do |actual|
61
+ return "expected #{actual.inspect} to be an Amount within #{@within.inspect} of #{@expected.inspect}" unless actual.is_a?(Amount)
62
+
63
+ "expected #{actual.inspect} to be within #{@within.inspect} of #{@expected.inspect}"
64
+ end
65
+ end
66
+ end
67
+
68
+ def define_amount_column_matcher
69
+ RSpec::Matchers.define :have_amount_column do |name, *expected_arguments|
70
+ match do |record|
71
+ @name = name
72
+ @expected = Amount::RSpecSupport.coerce_amount_arguments(expected_arguments)
73
+ @definition = record.class.amount_attribute_definitions.fetch(name.to_sym)
74
+
75
+ @definition.read(record) == @expected &&
76
+ record.public_send(@definition.atomic_column).to_i == @expected.atomic &&
77
+ (@definition.fixed_symbol? ||
78
+ record.public_send(@definition.symbol_column) == @expected.symbol.to_s)
79
+ end
80
+
81
+ failure_message do |record|
82
+ "expected #{record.inspect} to have #{@name} column matching #{@expected.inspect}"
83
+ end
84
+ end
85
+ end
86
+
87
+ def define_amount_sum_matcher
88
+ RSpec::Matchers.define :match_amounts do |expected_hash|
89
+ match do |actual_hash|
90
+ @expected = expected_hash.to_h do |symbol, value|
91
+ amount = Amount.new(value, symbol)
92
+ [amount.symbol, amount]
93
+ end
94
+ @actual = Amount::RSpecSupport.normalize_amount_sums(actual_hash)
95
+
96
+ @actual == @expected
97
+ end
98
+
99
+ failure_message do |_actual_hash|
100
+ "expected grouped amounts #{@actual.inspect} to match #{@expected.inspect}"
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ # Shared coercion helpers for opt-in RSpec integrations.
5
+ module RSpecSupport
6
+ module_function
7
+
8
+ def coerce_amount_arguments(arguments)
9
+ case arguments.length
10
+ when 1
11
+ coerce_amount(arguments.first)
12
+ when 2
13
+ Amount.new(arguments.last, arguments.first)
14
+ else
15
+ raise ArgumentError, "expected an Amount, a parse string, or a symbol/value pair"
16
+ end
17
+ end
18
+
19
+ def coerce_amount(value)
20
+ case value
21
+ when Amount then value
22
+ when String then Amount.parse(value)
23
+ when Hash then Amount.load(value)
24
+ else
25
+ raise ArgumentError, "cannot coerce #{value.inspect} into an Amount"
26
+ end
27
+ end
28
+
29
+ def coerce_delta(expected_amount, within)
30
+ case within
31
+ when Amount
32
+ within
33
+ when Integer, Float, BigDecimal, Rational, String
34
+ Amount.new(within, expected_amount.symbol)
35
+ else
36
+ raise ArgumentError, "cannot coerce #{within.inspect} into an amount delta"
37
+ end
38
+ end
39
+
40
+ def normalize_amount_sums(sum_hash)
41
+ sum_hash.to_h do |symbol, atomic|
42
+ amount = Amount.new(atomic, symbol, from: :atomic)
43
+ [amount.symbol, amount]
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ # Converts amounts to and from the versioned hash payload.
5
+ class Serializer
6
+ VERSION = 1
7
+
8
+ def self.dump(amount)
9
+ {
10
+ v: VERSION,
11
+ atomic: amount.atomic.to_s,
12
+ symbol: amount.symbol.to_s
13
+ }
14
+ end
15
+
16
+ def self.load(payload)
17
+ version = payload[:v] || payload["v"]
18
+ validate_version!(version)
19
+
20
+ Amount.new(
21
+ payload.fetch(:atomic) { payload.fetch("atomic") },
22
+ payload.fetch(:symbol) { payload.fetch("symbol") },
23
+ from: :atomic
24
+ )
25
+ end
26
+
27
+ def self.validate_version!(version)
28
+ return if version.nil? || version == VERSION
29
+
30
+ raise InvalidInput, "unsupported amount serialization version: #{version}"
31
+ end
32
+
33
+ private_class_method :validate_version!
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ VERSION = "0.0.1"
5
+ end