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
|
@@ -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
|
data/lib/amount/rspec.rb
ADDED
|
@@ -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
|