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.
- checksums.yaml +7 -0
- data/.rubocop.yml +89 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +402 -0
- data/Rakefile +18 -0
- data/bin/console +8 -0
- data/bin/setup +4 -0
- data/lib/amount/active_record/amount_validator.rb +115 -0
- data/lib/amount/active_record/attribute_definition.rb +192 -0
- data/lib/amount/active_record/migration_methods.rb +74 -0
- data/lib/amount/active_record/model.rb +140 -0
- data/lib/amount/active_record/rspec.rb +8 -0
- data/lib/amount/active_record/type.rb +106 -0
- data/lib/amount/active_record.rb +44 -0
- data/lib/amount/display.rb +82 -0
- data/lib/amount/parser.rb +50 -0
- data/lib/amount/registry/generated_constructors.rb +62 -0
- data/lib/amount/registry.rb +236 -0
- data/lib/amount/rspec.rb +27 -0
- data/lib/amount/rspec_matchers.rb +105 -0
- data/lib/amount/rspec_support.rb +47 -0
- data/lib/amount/serializer.rb +35 -0
- data/lib/amount/version.rb +5 -0
- data/lib/amount.rb +494 -0
- data/test/dummy/app/models/holding.rb +7 -0
- data/test/dummy/bin/rails +8 -0
- data/test/dummy/config/application.rb +15 -0
- data/test/dummy/config/database.yml +11 -0
- data/test/dummy/config/environment.rb +6 -0
- data/test/dummy/db/schema.rb +9 -0
- data/test/dummy/log/development.log +0 -0
- data/test/dummy/log/test.log +0 -0
- data/test/postgresql_integration_test.rb +71 -0
- data/test/support/amount_test_support.rb +38 -0
- data/test/test_active_record.rb +312 -0
- data/test/test_amount.rb +472 -0
- data/test/test_helper.rb +4 -0
- metadata +105 -0
data/lib/amount.rb
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "forwardable"
|
|
5
|
+
|
|
6
|
+
require_relative "amount/version"
|
|
7
|
+
require_relative "amount/registry"
|
|
8
|
+
require_relative "amount/display"
|
|
9
|
+
require_relative "amount/parser"
|
|
10
|
+
require_relative "amount/serializer"
|
|
11
|
+
|
|
12
|
+
# Represents a precise quantity of a registered fungible type.
|
|
13
|
+
#
|
|
14
|
+
# `Amount` stores its value as an arbitrary-precision atomic `Integer` in the
|
|
15
|
+
# smallest unit configured for the registered symbol. UI values are parsed from
|
|
16
|
+
# strings or decimals, while integer inputs are treated as atomic counts unless
|
|
17
|
+
# `from:` overrides inference.
|
|
18
|
+
#
|
|
19
|
+
# @example Constructing from a UI value
|
|
20
|
+
# Amount.register :USDC, decimals: 6
|
|
21
|
+
#
|
|
22
|
+
# Amount.usdc("1.50").atomic
|
|
23
|
+
# # => 1500000
|
|
24
|
+
#
|
|
25
|
+
# @example Constructing from an atomic value
|
|
26
|
+
# Amount.usdc(1_500_000, from: :atomic).decimal.to_s("F")
|
|
27
|
+
# # => "1.5"
|
|
28
|
+
class Amount
|
|
29
|
+
include Comparable
|
|
30
|
+
extend Forwardable
|
|
31
|
+
|
|
32
|
+
class Error < StandardError; end
|
|
33
|
+
class TypeMismatch < Error; end
|
|
34
|
+
class InvalidInput < Error; end
|
|
35
|
+
class UnregisteredType < Error; end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# @return [Amount::Registry]
|
|
39
|
+
# @example Accessing the shared registry
|
|
40
|
+
# Amount.registry.locked?
|
|
41
|
+
# # => false
|
|
42
|
+
def registry
|
|
43
|
+
Amount.instance_variable_get(:@registry) ||
|
|
44
|
+
replace_registry(Registry.new)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param symbol [Symbol, String]
|
|
48
|
+
# @param opts [Hash]
|
|
49
|
+
# @return [void]
|
|
50
|
+
# @example Registering a type
|
|
51
|
+
# Amount.register :USDC,
|
|
52
|
+
# decimals: 6,
|
|
53
|
+
# display_symbol: "$",
|
|
54
|
+
# display_position: :prefix,
|
|
55
|
+
# ui_decimals: 2
|
|
56
|
+
def register(symbol, **opts)
|
|
57
|
+
registry.register(symbol, **opts)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param from [Symbol, String]
|
|
61
|
+
# @param to [Symbol, String]
|
|
62
|
+
# @param rate [String, Numeric, BigDecimal]
|
|
63
|
+
# @return [void]
|
|
64
|
+
# @example Registering a directional default rate
|
|
65
|
+
# Amount.register_default_rate :USD, :USDC, "1"
|
|
66
|
+
def register_default_rate(from, to, rate)
|
|
67
|
+
registry.register_default_rate(from, to, rate)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Parses the compact client-facing string representation.
|
|
71
|
+
#
|
|
72
|
+
# Accepts either the default form `SYMBOL|amount` or the explicit versioned
|
|
73
|
+
# form `v1:SYMBOL|amount`.
|
|
74
|
+
#
|
|
75
|
+
# @param str [String]
|
|
76
|
+
# @return [Amount]
|
|
77
|
+
# @raise [InvalidInput]
|
|
78
|
+
# @example Parsing the default compact format
|
|
79
|
+
# Amount.parse("USDC|1.50")
|
|
80
|
+
#
|
|
81
|
+
# @example Parsing the explicit versioned compact format
|
|
82
|
+
# Amount.parse("v1:USDC|1.50")
|
|
83
|
+
def parse(str)
|
|
84
|
+
Parser.new(str).parse
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Temporarily swaps the global registry. Intended for tests.
|
|
88
|
+
#
|
|
89
|
+
# @param registry [Amount::Registry]
|
|
90
|
+
# @yield
|
|
91
|
+
# @return [Object]
|
|
92
|
+
# @example Using a temporary registry
|
|
93
|
+
# test_registry = Amount::Registry.new
|
|
94
|
+
# Amount.with_registry(test_registry) do
|
|
95
|
+
# Amount.register :TEST, decimals: 2
|
|
96
|
+
# end
|
|
97
|
+
def with_registry(registry)
|
|
98
|
+
original = Amount.instance_variable_get(:@registry)
|
|
99
|
+
replace_registry(registry)
|
|
100
|
+
yield
|
|
101
|
+
ensure
|
|
102
|
+
replace_registry(original)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def replace_registry(registry)
|
|
108
|
+
current = Amount.instance_variable_get(:@registry)
|
|
109
|
+
current&.remove_generated_methods!
|
|
110
|
+
Amount.instance_variable_set(:@registry, registry)
|
|
111
|
+
registry&.activate_generated_methods!
|
|
112
|
+
registry
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
attr_reader :atomic, :symbol
|
|
117
|
+
|
|
118
|
+
# Creates an amount for a registered symbol.
|
|
119
|
+
#
|
|
120
|
+
# Input inference rules:
|
|
121
|
+
# - `Integer` => atomic units
|
|
122
|
+
# - `String` => UI decimal value
|
|
123
|
+
# - `Float`, `BigDecimal`, `Rational` => UI decimal value
|
|
124
|
+
# - `from:` overrides inference explicitly
|
|
125
|
+
#
|
|
126
|
+
# @param value [Integer, String, Float, BigDecimal, Rational]
|
|
127
|
+
# @param symbol [Symbol, String] registered type identifier
|
|
128
|
+
# @param from [Symbol, nil] one of `:atomic`, `:ui`, or `:float`
|
|
129
|
+
# @raise [Amount::Registry::UnknownType] if the symbol is not registered
|
|
130
|
+
# @raise [InvalidInput] if the value cannot be interpreted for the symbol
|
|
131
|
+
# @example Integer inputs are atomic by default
|
|
132
|
+
# Amount.new(1_500_000, :USDC).decimal.to_s("F")
|
|
133
|
+
# # => "1.5"
|
|
134
|
+
#
|
|
135
|
+
# @example String inputs are UI values by default
|
|
136
|
+
# Amount.new("1.50", :USDC).atomic
|
|
137
|
+
# # => 1500000
|
|
138
|
+
def initialize(value, symbol, from: nil)
|
|
139
|
+
@symbol = symbol.to_sym
|
|
140
|
+
@entry = self.class.registry.lookup(@symbol)
|
|
141
|
+
|
|
142
|
+
if @entry.amount_class != self.class && @entry.amount_class != Amount
|
|
143
|
+
raise InvalidInput, "use #{@entry.amount_class}.new for #{@symbol}" unless instance_of?(@entry.amount_class)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
@atomic = infer_value(from, value)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [Amount::Registry::Entry]
|
|
150
|
+
# @example Accessing display configuration for this amount
|
|
151
|
+
# Amount.usdc("1").registry_entry.ui_decimals
|
|
152
|
+
# # => 2
|
|
153
|
+
def registry_entry
|
|
154
|
+
@entry
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [Integer]
|
|
158
|
+
# @example Reading the registered storage precision
|
|
159
|
+
# Amount.usdc("1").decimals
|
|
160
|
+
# # => 6
|
|
161
|
+
def decimals
|
|
162
|
+
@entry.decimals
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @return [BigDecimal]
|
|
166
|
+
# @example Converting the atomic value back to a decimal quantity
|
|
167
|
+
# Amount.usdc(1_500_000, from: :atomic).decimal.to_s("F")
|
|
168
|
+
# # => "1.5"
|
|
169
|
+
def decimal
|
|
170
|
+
BigDecimal(@atomic) / (BigDecimal(10)**decimals)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @return [Amount::Display]
|
|
174
|
+
# @example Delegating formatting concerns
|
|
175
|
+
# Amount.usdc("1.50").display.ui
|
|
176
|
+
# # => "$1.50"
|
|
177
|
+
def display
|
|
178
|
+
@display ||= Display.new(self)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def_delegators :display, :formatted, :ui, :to_s, :in_unit
|
|
182
|
+
|
|
183
|
+
# @return [String]
|
|
184
|
+
# @example Console-friendly inspection
|
|
185
|
+
# Amount.usdc("1.50").inspect
|
|
186
|
+
# # => "#<Amount USDC $1.50>"
|
|
187
|
+
def inspect
|
|
188
|
+
"#<#{self.class} #{symbol} #{ui}>"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @return [Boolean]
|
|
192
|
+
# @example
|
|
193
|
+
# Amount.usdc(0, from: :atomic).zero?
|
|
194
|
+
# # => true
|
|
195
|
+
def zero? = @atomic.zero?
|
|
196
|
+
|
|
197
|
+
# @return [Boolean]
|
|
198
|
+
# @example
|
|
199
|
+
# Amount.usdc("1").positive?
|
|
200
|
+
# # => true
|
|
201
|
+
def positive? = @atomic.positive?
|
|
202
|
+
|
|
203
|
+
# @return [Boolean]
|
|
204
|
+
# @example
|
|
205
|
+
# Amount.usdc("-1").negative?
|
|
206
|
+
# # => true
|
|
207
|
+
def negative? = @atomic.negative?
|
|
208
|
+
|
|
209
|
+
# @param other [Object]
|
|
210
|
+
# @return [Boolean]
|
|
211
|
+
# @example
|
|
212
|
+
# Amount.usdc("1").same_type?(Amount.usdc("2"))
|
|
213
|
+
# # => true
|
|
214
|
+
def same_type?(other)
|
|
215
|
+
other.is_a?(Amount) && other.symbol == symbol
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @return [Amount]
|
|
219
|
+
# @example
|
|
220
|
+
# Amount.usdc("-1").abs
|
|
221
|
+
# # => #<Amount USDC $1.00>
|
|
222
|
+
def abs
|
|
223
|
+
build(@atomic.abs)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# @return [Amount]
|
|
227
|
+
# @example
|
|
228
|
+
# -Amount.usdc("1")
|
|
229
|
+
# # => #<Amount USDC -$1.00>
|
|
230
|
+
def -@
|
|
231
|
+
build(-@atomic)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# @param other [Amount]
|
|
235
|
+
# @return [Amount]
|
|
236
|
+
# @raise [TypeMismatch]
|
|
237
|
+
# @example Same-type addition
|
|
238
|
+
# Amount.usdc("1.50") + Amount.usdc("0.50")
|
|
239
|
+
#
|
|
240
|
+
# @example Cross-type addition using a registered directional rate
|
|
241
|
+
# Amount.register_default_rate :USD, :USDC, "1"
|
|
242
|
+
# Amount.usdc("10.00") + Amount.new("5.00", :USD)
|
|
243
|
+
def +(other)
|
|
244
|
+
rhs = coerce_other_to_self_type!(other)
|
|
245
|
+
build(@atomic + rhs.atomic)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @param other [Amount]
|
|
249
|
+
# @return [Amount]
|
|
250
|
+
# @raise [TypeMismatch]
|
|
251
|
+
# @example
|
|
252
|
+
# Amount.usdc("2.00") - Amount.usdc("0.50")
|
|
253
|
+
def -(other)
|
|
254
|
+
rhs = coerce_other_to_self_type!(other)
|
|
255
|
+
build(@atomic - rhs.atomic)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# @param scalar [Numeric]
|
|
259
|
+
# @return [Amount]
|
|
260
|
+
# @raise [TypeMismatch]
|
|
261
|
+
# @example
|
|
262
|
+
# Amount.usdc("1.25") * 2
|
|
263
|
+
def *(scalar)
|
|
264
|
+
ensure_scalar!(scalar)
|
|
265
|
+
build((BigDecimal(@atomic) * BigDecimal(scalar.to_s)).to_i)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# @param other [Amount, Numeric]
|
|
269
|
+
# @return [Amount, BigDecimal]
|
|
270
|
+
# @raise [TypeMismatch, ZeroDivisionError]
|
|
271
|
+
# @example Dividing by a scalar returns an amount
|
|
272
|
+
# Amount.usdc("1.00") / 2
|
|
273
|
+
#
|
|
274
|
+
# @example Dividing by an amount returns a ratio
|
|
275
|
+
# Amount.usdc("10.00") / Amount.usdc("2.00")
|
|
276
|
+
def /(other)
|
|
277
|
+
if other.is_a?(Amount)
|
|
278
|
+
ensure_same_type!(other)
|
|
279
|
+
raise ZeroDivisionError if other.zero?
|
|
280
|
+
|
|
281
|
+
BigDecimal(@atomic) / BigDecimal(other.atomic)
|
|
282
|
+
else
|
|
283
|
+
ensure_scalar!(other)
|
|
284
|
+
raise ZeroDivisionError if other.zero?
|
|
285
|
+
|
|
286
|
+
build((BigDecimal(@atomic) / BigDecimal(other.to_s)).to_i)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Splits into equal parts and returns the leftover explicitly.
|
|
291
|
+
#
|
|
292
|
+
# @param n [Integer]
|
|
293
|
+
# @return [Array<(Array<Amount>, Amount)>]
|
|
294
|
+
# @raise [ArgumentError] if `n` is not a positive integer
|
|
295
|
+
# @example
|
|
296
|
+
# parts, remainder = Amount.new(10, :LOGS).split(3)
|
|
297
|
+
# parts.map(&:atomic)
|
|
298
|
+
# # => [3, 3, 3]
|
|
299
|
+
# remainder.atomic
|
|
300
|
+
# # => 1
|
|
301
|
+
def split(n)
|
|
302
|
+
raise ArgumentError, "n must be positive" unless n.is_a?(Integer) && n.positive?
|
|
303
|
+
|
|
304
|
+
sign = atomic_sign
|
|
305
|
+
base, remainder = @atomic.abs.divmod(n)
|
|
306
|
+
parts = Array.new(n) { build(sign * base) }
|
|
307
|
+
|
|
308
|
+
[parts, build(sign * remainder)]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Allocates proportionally by integer weights and returns the leftover explicitly.
|
|
312
|
+
#
|
|
313
|
+
# @param weights [Array<Integer>]
|
|
314
|
+
# @return [Array<(Array<Amount>, Amount)>]
|
|
315
|
+
# @raise [ArgumentError] if weights are empty, negative, or sum to zero
|
|
316
|
+
# @example
|
|
317
|
+
# parts, remainder = Amount.new(10, :LOGS).allocate([1, 1, 2])
|
|
318
|
+
# parts.map(&:atomic)
|
|
319
|
+
# # => [2, 2, 5]
|
|
320
|
+
# remainder.atomic
|
|
321
|
+
# # => 1
|
|
322
|
+
def allocate(weights)
|
|
323
|
+
raise ArgumentError, "weights must be non-empty" if weights.empty?
|
|
324
|
+
raise ArgumentError, "weights must be non-negative integers" unless weights.all? { |weight| weight.is_a?(Integer) && weight >= 0 }
|
|
325
|
+
|
|
326
|
+
total = weights.sum
|
|
327
|
+
raise ArgumentError, "weights must sum to positive value" unless total.positive?
|
|
328
|
+
|
|
329
|
+
sign = atomic_sign
|
|
330
|
+
absolute_atomic = @atomic.abs
|
|
331
|
+
allocations = weights.map { |weight| absolute_atomic * weight / total }
|
|
332
|
+
remainder = absolute_atomic - allocations.sum
|
|
333
|
+
|
|
334
|
+
parts = allocations.map { |allocation| build(sign * allocation) }
|
|
335
|
+
[parts, build(sign * remainder)]
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# @param other [Object]
|
|
339
|
+
# @return [-1, 0, 1, nil]
|
|
340
|
+
# @example
|
|
341
|
+
# Amount.usdc("1") <=> Amount.usdc("2")
|
|
342
|
+
# # => -1
|
|
343
|
+
def <=>(other)
|
|
344
|
+
return nil unless other.is_a?(Amount)
|
|
345
|
+
|
|
346
|
+
comparable = coerce_other_to_self_type(other)
|
|
347
|
+
return nil unless comparable
|
|
348
|
+
|
|
349
|
+
@atomic <=> comparable.atomic
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# @param other [Object]
|
|
353
|
+
# @return [Boolean]
|
|
354
|
+
# @example
|
|
355
|
+
# Amount.usdc("1.50") == Amount.usdc("1.50")
|
|
356
|
+
# # => true
|
|
357
|
+
def ==(other)
|
|
358
|
+
same_type?(other) && @atomic == other.atomic
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @param other [Object]
|
|
362
|
+
# @return [Boolean]
|
|
363
|
+
# @example Hash-key equality keeps class and symbol identity
|
|
364
|
+
# Amount.usdc("1").eql?(Amount.usdc("1"))
|
|
365
|
+
# # => true
|
|
366
|
+
def eql?(other)
|
|
367
|
+
other.class == self.class && symbol == other.symbol && @atomic == other.atomic
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# @return [Integer]
|
|
371
|
+
# @example
|
|
372
|
+
# { Amount.usdc("1") => :ok }[Amount.usdc("1")]
|
|
373
|
+
# # => :ok
|
|
374
|
+
def hash
|
|
375
|
+
[self.class, symbol, @atomic].hash
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# @param target_symbol [Symbol, String]
|
|
379
|
+
# @param rate [String, Numeric, BigDecimal, nil]
|
|
380
|
+
# @return [Amount]
|
|
381
|
+
# @raise [Amount::Registry::NoDefaultRate] if no explicit or registered rate is available
|
|
382
|
+
# @example Using an explicit one-off rate
|
|
383
|
+
# Amount.usdc("100").to(:GOLD, rate: "0.00042")
|
|
384
|
+
#
|
|
385
|
+
# @example Using a registered default rate
|
|
386
|
+
# Amount.register_default_rate :USDC, :USD, "1"
|
|
387
|
+
# Amount.usdc("1.50").to(:USD)
|
|
388
|
+
def to(target_symbol, rate: nil)
|
|
389
|
+
target_symbol = target_symbol.to_sym
|
|
390
|
+
return self.class.new(@atomic, symbol, from: :atomic) if target_symbol == symbol
|
|
391
|
+
|
|
392
|
+
rate = resolve_rate(target_symbol, rate)
|
|
393
|
+
target_entry = self.class.registry.lookup(target_symbol)
|
|
394
|
+
|
|
395
|
+
decimal_result = decimal * BigDecimal(rate.to_s)
|
|
396
|
+
atomic_result = (decimal_result * (BigDecimal(10)**target_entry.decimals)).to_i
|
|
397
|
+
|
|
398
|
+
target_entry.amount_class.new(atomic_result, target_symbol, from: :atomic)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# @return [Hash]
|
|
402
|
+
# @example
|
|
403
|
+
# Amount.usdc("1.50").to_h
|
|
404
|
+
# # => { v: 1, atomic: "1500000", symbol: "USDC" }
|
|
405
|
+
def to_h
|
|
406
|
+
Serializer.dump(self)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# @param hash [Hash]
|
|
410
|
+
# @return [Amount]
|
|
411
|
+
# @raise [InvalidInput] for unsupported serialized versions
|
|
412
|
+
# @example Loading the current versioned payload
|
|
413
|
+
# Amount.load(v: 1, atomic: "1500000", symbol: "USDC")
|
|
414
|
+
#
|
|
415
|
+
# @example Loading the legacy unversioned payload
|
|
416
|
+
# Amount.load(atomic: 1500000, symbol: :USDC)
|
|
417
|
+
def self.load(hash)
|
|
418
|
+
Serializer.load(hash)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
private
|
|
422
|
+
|
|
423
|
+
def build(atomic_value)
|
|
424
|
+
self.class.new(atomic_value, symbol, from: :atomic)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def atomic_sign
|
|
428
|
+
return 1 if @atomic.positive?
|
|
429
|
+
return(-1) if @atomic.negative?
|
|
430
|
+
|
|
431
|
+
0
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def ensure_same_type!(other)
|
|
435
|
+
return if same_type?(other)
|
|
436
|
+
|
|
437
|
+
raise TypeMismatch, "type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def ensure_scalar!(value)
|
|
441
|
+
return if value.is_a?(Integer) || value.is_a?(Float) ||
|
|
442
|
+
value.is_a?(BigDecimal) || value.is_a?(Rational)
|
|
443
|
+
|
|
444
|
+
raise TypeMismatch, "expected scalar, got #{value.class}"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def resolve_rate(target, provided)
|
|
448
|
+
return provided if provided
|
|
449
|
+
|
|
450
|
+
self.class.registry.default_rate(symbol, target)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def infer_value(from, value)
|
|
454
|
+
case from || infer_type(value)
|
|
455
|
+
when :atomic then value.to_i
|
|
456
|
+
when :ui then ui_to_atomic(value)
|
|
457
|
+
when :float then ui_to_atomic(value.to_s)
|
|
458
|
+
else
|
|
459
|
+
raise InvalidInput, "unknown amount format: #{value.inspect}"
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def infer_type(value)
|
|
464
|
+
case value
|
|
465
|
+
when Integer then :atomic
|
|
466
|
+
when String then :ui
|
|
467
|
+
when Float, BigDecimal, Rational then :float
|
|
468
|
+
else
|
|
469
|
+
raise InvalidInput, "cannot infer type for #{value.class}"
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def ui_to_atomic(value)
|
|
474
|
+
(BigDecimal(value.to_s) * (BigDecimal(10)**decimals)).to_i
|
|
475
|
+
rescue ArgumentError
|
|
476
|
+
raise InvalidInput, "cannot parse #{value.inspect} as #{symbol}"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def coerce_other_to_self_type!(other)
|
|
480
|
+
coerce_other_to_self_type(other) || raise(
|
|
481
|
+
TypeMismatch,
|
|
482
|
+
"type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
|
|
483
|
+
)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def coerce_other_to_self_type(other)
|
|
487
|
+
return other if same_type?(other)
|
|
488
|
+
return unless other.is_a?(Amount)
|
|
489
|
+
|
|
490
|
+
other.to(symbol)
|
|
491
|
+
rescue Registry::NoDefaultRate
|
|
492
|
+
nil
|
|
493
|
+
end
|
|
494
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require "active_model/railtie"
|
|
5
|
+
require "active_record/railtie"
|
|
6
|
+
|
|
7
|
+
module Dummy
|
|
8
|
+
class Application < Rails::Application
|
|
9
|
+
config.root = File.expand_path("..", __dir__)
|
|
10
|
+
config.eager_load = false
|
|
11
|
+
config.secret_key_base = "amounts-test-secret-key-base"
|
|
12
|
+
config.hosts.clear
|
|
13
|
+
config.active_support.deprecation = :stderr
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ActiveRecord::Schema.define(version: 1) do
|
|
4
|
+
create_table :holdings, force: true do |t|
|
|
5
|
+
t.amount :amount, null: true
|
|
6
|
+
t.amount :fee, symbol: :SOL, null: true
|
|
7
|
+
t.amount :reserve, precision: 40, default: "USDC|1.25", null: false
|
|
8
|
+
end
|
|
9
|
+
end
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require_relative "../../lib/amount"
|
|
5
|
+
|
|
6
|
+
module AmountTestSupport
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def register_default_types!
|
|
10
|
+
Amount.registry.clear!
|
|
11
|
+
Amount.register :USDC, decimals: 6, display_symbol: "$",
|
|
12
|
+
display_position: :prefix, ui_decimals: 2
|
|
13
|
+
Amount.register :USD, decimals: 2, display_symbol: "$",
|
|
14
|
+
display_position: :prefix, ui_decimals: 2
|
|
15
|
+
Amount.register :SOL, decimals: 9, display_symbol: "SOL",
|
|
16
|
+
display_position: :suffix, ui_decimals: 4
|
|
17
|
+
Amount.register :GOLD, decimals: 8, display_symbol: "oz t",
|
|
18
|
+
display_position: :suffix, ui_decimals: 4,
|
|
19
|
+
display_units: {
|
|
20
|
+
oz_t: { scale: 1, symbol: "oz t", ui_decimals: 4 },
|
|
21
|
+
gram: { scale: "31.1035", symbol: "g", ui_decimals: 2 },
|
|
22
|
+
kg: { scale: "0.0311035", symbol: "kg", ui_decimals: 5 }
|
|
23
|
+
},
|
|
24
|
+
default_display: :oz_t
|
|
25
|
+
Amount.register :LOGS, decimals: 0, display_symbol: "logs",
|
|
26
|
+
display_position: :suffix, ui_decimals: 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def register_active_record_types!
|
|
30
|
+
Amount.registry.clear!
|
|
31
|
+
Amount.register :USDC, decimals: 6, display_symbol: "$",
|
|
32
|
+
display_position: :prefix, ui_decimals: 2
|
|
33
|
+
Amount.register :USD, decimals: 2, display_symbol: "$",
|
|
34
|
+
display_position: :prefix, ui_decimals: 2
|
|
35
|
+
Amount.register :SOL, decimals: 9, display_symbol: "SOL",
|
|
36
|
+
display_position: :suffix, ui_decimals: 4
|
|
37
|
+
end
|
|
38
|
+
end
|