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,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Describes one `has_amount` attribute and its backing columns.
|
|
6
|
+
class AttributeDefinition
|
|
7
|
+
attr_reader :name, :type, :symbol
|
|
8
|
+
|
|
9
|
+
def initialize(name, symbol: nil)
|
|
10
|
+
@name = name.to_sym
|
|
11
|
+
@symbol = symbol&.to_sym
|
|
12
|
+
@type = Type.new(symbol: @symbol)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fixed_symbol?
|
|
16
|
+
!@symbol.nil?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def atomic_column
|
|
20
|
+
"#{name}_atomic"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def symbol_column
|
|
24
|
+
"#{name}_symbol"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def component_columns
|
|
28
|
+
columns = [atomic_column]
|
|
29
|
+
columns << symbol_column unless fixed_symbol?
|
|
30
|
+
columns
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read(record)
|
|
34
|
+
type.deserialize(record.read_attribute(atomic_column), resolved_symbol(record))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def write(record, value)
|
|
38
|
+
if value.nil?
|
|
39
|
+
record.write_attribute(atomic_column, nil)
|
|
40
|
+
record.write_attribute(symbol_column, nil) unless fixed_symbol?
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
record.write_attribute(atomic_column, value.atomic.to_s)
|
|
45
|
+
record.write_attribute(symbol_column, value.symbol.to_s) unless fixed_symbol?
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_from_values(values)
|
|
50
|
+
type.deserialize(values[atomic_column], fixed_symbol? ? symbol : values[symbol_column])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def query_relation(model, value)
|
|
54
|
+
amount = type.cast(value)
|
|
55
|
+
relation_for_symbol(model, amount).where(atomic_column => amount.atomic)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def greater_than_relation(model, value)
|
|
59
|
+
query_relation_with_operator(model, value, :>)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def greater_than_or_equal_relation(model, value)
|
|
63
|
+
query_relation_with_operator(model, value, :>=)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def less_than_relation(model, value)
|
|
67
|
+
query_relation_with_operator(model, value, :<)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def less_than_or_equal_relation(model, value)
|
|
71
|
+
query_relation_with_operator(model, value, :<=)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def between_relation(model, lower, upper)
|
|
75
|
+
lower_amount = type.cast(lower)
|
|
76
|
+
upper_amount = type.cast(upper)
|
|
77
|
+
ensure_same_query_symbol!(lower_amount, upper_amount)
|
|
78
|
+
|
|
79
|
+
relation_for_symbol(model, lower_amount)
|
|
80
|
+
.where("#{atomic_column} >= ? AND #{atomic_column} <= ?", lower_amount.atomic, upper_amount.atomic)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def currency_relation(model, symbol)
|
|
84
|
+
model.where(symbol_column => symbol.to_s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def changed?(record)
|
|
88
|
+
component_columns.any? { |column| record.will_save_change_to_attribute?(column) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def change(record)
|
|
92
|
+
return unless changed?(record)
|
|
93
|
+
|
|
94
|
+
[value_in_database(record), read(record)]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def value_in_database(record)
|
|
98
|
+
values = component_columns.to_h do |column|
|
|
99
|
+
[column, record.attribute_in_database(column)]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
build_from_values(values)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def saved_change?(record)
|
|
106
|
+
component_columns.any? { |column| record.saved_change_to_attribute?(column) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def saved_change(record)
|
|
110
|
+
return unless saved_change?(record)
|
|
111
|
+
|
|
112
|
+
[
|
|
113
|
+
build_from_values(previous_values(record, before: true)),
|
|
114
|
+
build_from_values(previous_values(record, before: false))
|
|
115
|
+
]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def before_last_save(record)
|
|
119
|
+
saved_change(record)&.first
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate(record)
|
|
123
|
+
add_assignment_error(record)
|
|
124
|
+
|
|
125
|
+
atomic = record.read_attribute(atomic_column)
|
|
126
|
+
return if atomic.nil? && fixed_symbol?
|
|
127
|
+
|
|
128
|
+
if fixed_symbol?
|
|
129
|
+
validate_deserialization(record, atomic, symbol)
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
symbol_value = record.read_attribute(symbol_column)
|
|
134
|
+
if atomic.nil? != symbol_value.nil?
|
|
135
|
+
record.errors.add(name, "must set both atomic and symbol or neither")
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
return if atomic.nil? && symbol_value.nil?
|
|
140
|
+
|
|
141
|
+
validate_deserialization(record, atomic, symbol_value)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def resolved_symbol(record)
|
|
147
|
+
fixed_symbol? ? symbol : record.read_attribute(symbol_column)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def query_relation_with_operator(model, value, operator)
|
|
151
|
+
amount = type.cast(value)
|
|
152
|
+
relation_for_symbol(model, amount)
|
|
153
|
+
.where("#{atomic_column} #{operator} ?", amount.atomic)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def relation_for_symbol(model, amount)
|
|
157
|
+
return model if fixed_symbol?
|
|
158
|
+
|
|
159
|
+
model.where(symbol_column => amount.symbol.to_s)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def ensure_same_query_symbol!(left, right)
|
|
163
|
+
return if left.symbol == right.symbol
|
|
164
|
+
|
|
165
|
+
raise ::Amount::TypeMismatch, "query bounds must use the same symbol: #{left.symbol} vs #{right.symbol}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def previous_values(record, before:)
|
|
169
|
+
component_columns.to_h do |column|
|
|
170
|
+
change = record.saved_change_to_attribute(column)
|
|
171
|
+
value = if change
|
|
172
|
+
change.public_send(before ? :first : :last)
|
|
173
|
+
else
|
|
174
|
+
record.read_attribute(column)
|
|
175
|
+
end
|
|
176
|
+
[column, value]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def add_assignment_error(record)
|
|
181
|
+
message = record.send(:pending_amount_assignment_errors)[name]
|
|
182
|
+
record.errors.add(name, message) if message
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def validate_deserialization(record, atomic, currency_value)
|
|
186
|
+
type.deserialize(atomic, currency_value)
|
|
187
|
+
rescue ::Amount::Error => error
|
|
188
|
+
record.errors.add(name, error.message)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Adds `t.amount` to migrations.
|
|
6
|
+
#
|
|
7
|
+
# Multi-symbol amounts create `*_atomic` and `*_symbol` columns. Fixed
|
|
8
|
+
# symbol amounts create only the atomic column.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# create_table :holdings do |t|
|
|
12
|
+
# t.amount :amount
|
|
13
|
+
# t.amount :fee, symbol: :SOL
|
|
14
|
+
# t.amount :reserve, precision: 40
|
|
15
|
+
# end
|
|
16
|
+
module MigrationMethods
|
|
17
|
+
# Adds one amount attribute to the table definition.
|
|
18
|
+
#
|
|
19
|
+
# @param name [Symbol, String] logical attribute name
|
|
20
|
+
# @param precision [Integer] numeric precision for the atomic column
|
|
21
|
+
# @param symbol [Symbol, nil] fixed symbol for a single-column amount
|
|
22
|
+
# @param options [Hash] standard column options applied to the generated columns
|
|
23
|
+
# @return [void]
|
|
24
|
+
def amount(name, precision: 78, symbol: nil, **options)
|
|
25
|
+
default = options.delete(:default)
|
|
26
|
+
null = options.key?(:null) ? options[:null] : nil
|
|
27
|
+
comment = options[:comment]
|
|
28
|
+
|
|
29
|
+
defaults = normalize_defaults(default, symbol)
|
|
30
|
+
|
|
31
|
+
decimal_options = {
|
|
32
|
+
precision:,
|
|
33
|
+
scale: 0,
|
|
34
|
+
default: defaults[:atomic],
|
|
35
|
+
comment:
|
|
36
|
+
}
|
|
37
|
+
decimal_options[:null] = null unless null.nil?
|
|
38
|
+
decimal(name_to_atomic(name), **decimal_options.compact)
|
|
39
|
+
|
|
40
|
+
return if symbol
|
|
41
|
+
|
|
42
|
+
string_options = {
|
|
43
|
+
limit: 10,
|
|
44
|
+
default: defaults[:symbol],
|
|
45
|
+
comment:
|
|
46
|
+
}
|
|
47
|
+
string_options[:null] = null unless null.nil?
|
|
48
|
+
string(name_to_symbol(name), **string_options.compact)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def normalize_defaults(default, symbol)
|
|
54
|
+
return {} if default.nil?
|
|
55
|
+
|
|
56
|
+
type = Type.new(symbol:)
|
|
57
|
+
amount = type.cast(default)
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
atomic: amount.atomic.to_s,
|
|
61
|
+
symbol: symbol ? nil : amount.symbol.to_s
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def name_to_atomic(name)
|
|
66
|
+
:"#{name}_atomic"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def name_to_symbol(name)
|
|
70
|
+
:"#{name}_symbol"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Provides `has_amount` for ActiveRecord models.
|
|
6
|
+
module Model
|
|
7
|
+
def has_amount(name, symbol: nil)
|
|
8
|
+
definition = AttributeDefinition.new(name, symbol:)
|
|
9
|
+
amount_attribute_definitions[definition.name] = definition
|
|
10
|
+
|
|
11
|
+
define_amount_reader(definition)
|
|
12
|
+
define_amount_writer(definition)
|
|
13
|
+
define_amount_dirty_tracking(definition)
|
|
14
|
+
define_amount_scopes(definition)
|
|
15
|
+
define_amount_validation(definition)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def amount_attribute_definitions
|
|
19
|
+
@amount_attribute_definitions ||= {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def amount_component_columns(name)
|
|
23
|
+
amount_attribute_definition(name).component_columns
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def amount_atomic_column(name)
|
|
27
|
+
amount_attribute_definition(name).atomic_column
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def amount_symbol_column(name)
|
|
31
|
+
amount_attribute_definition(name).symbol_column
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def amount_attribute_definition(name)
|
|
37
|
+
amount_attribute_definitions.fetch(name.to_sym)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def define_amount_reader(definition)
|
|
41
|
+
define_method(definition.name) do
|
|
42
|
+
definition.read(self)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def define_amount_writer(definition)
|
|
47
|
+
define_method("#{definition.name}=") do |value|
|
|
48
|
+
clear_amount_assignment_error(definition.name)
|
|
49
|
+
definition.write(self, definition.type.cast(value))
|
|
50
|
+
rescue ::Amount::Error, ArgumentError => error
|
|
51
|
+
remember_amount_assignment_error(definition.name, error.message)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def define_amount_dirty_tracking(definition)
|
|
56
|
+
define_method("#{definition.name}_changed?") do
|
|
57
|
+
definition.changed?(self)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
define_method("#{definition.name}_was") do
|
|
61
|
+
definition.value_in_database(self)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
define_method("#{definition.name}_change") do
|
|
65
|
+
definition.change(self)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
define_method("saved_change_to_#{definition.name}?") do
|
|
69
|
+
definition.saved_change?(self)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
define_method("saved_change_to_#{definition.name}") do
|
|
73
|
+
definition.saved_change(self)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
define_method("#{definition.name}_before_last_save") do
|
|
77
|
+
definition.before_last_save(self)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def define_amount_scopes(definition)
|
|
82
|
+
define_method("where_#{definition.name}") do |value|
|
|
83
|
+
self.class.public_send("where_#{definition.name}", value)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
scope :"where_#{definition.name}", lambda { |value|
|
|
87
|
+
definition.query_relation(self, value)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
scope :"where_#{definition.name}_gt", lambda { |value|
|
|
91
|
+
definition.greater_than_relation(self, value)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
scope :"where_#{definition.name}_gte", lambda { |value|
|
|
95
|
+
definition.greater_than_or_equal_relation(self, value)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
scope :"where_#{definition.name}_lt", lambda { |value|
|
|
99
|
+
definition.less_than_relation(self, value)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
scope :"where_#{definition.name}_lte", lambda { |value|
|
|
103
|
+
definition.less_than_or_equal_relation(self, value)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
scope :"where_#{definition.name}_between", lambda { |lower, upper|
|
|
107
|
+
definition.between_relation(self, lower, upper)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return if definition.fixed_symbol?
|
|
111
|
+
|
|
112
|
+
scope :"#{definition.name}_in", lambda { |symbol|
|
|
113
|
+
definition.currency_relation(self, symbol)
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def define_amount_validation(definition)
|
|
118
|
+
validate do
|
|
119
|
+
definition.validate(self)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
module InstanceMethods
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def pending_amount_assignment_errors
|
|
128
|
+
@pending_amount_assignment_errors ||= {}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def remember_amount_assignment_error(name, message)
|
|
132
|
+
pending_amount_assignment_errors[name.to_sym] = message
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def clear_amount_assignment_error(name)
|
|
136
|
+
pending_amount_assignment_errors.delete(name.to_sym)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Casts user input into Amount objects for the optional ActiveRecord adapter.
|
|
6
|
+
#
|
|
7
|
+
# The type is intentionally explicit:
|
|
8
|
+
# - strings are parsed using {Amount.parse}
|
|
9
|
+
# - hashes are loaded or interpreted as value/symbol pairs
|
|
10
|
+
# - raw numerics are only accepted for fixed-symbol attributes
|
|
11
|
+
#
|
|
12
|
+
# @example Casting a multi-symbol string value
|
|
13
|
+
# Amount::ActiveRecord::Type.new.cast("USDC|1.50")
|
|
14
|
+
#
|
|
15
|
+
# @example Casting a fixed-symbol numeric value
|
|
16
|
+
# Amount::ActiveRecord::Type.new(symbol: :SOL).cast(0.25)
|
|
17
|
+
class Type
|
|
18
|
+
attr_reader :fixed_symbol
|
|
19
|
+
|
|
20
|
+
# @param symbol [Symbol, String, nil]
|
|
21
|
+
# @example
|
|
22
|
+
# Amount::ActiveRecord::Type.new(symbol: :SOL)
|
|
23
|
+
def initialize(symbol: nil)
|
|
24
|
+
@fixed_symbol = symbol&.to_sym
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param value [Amount, String, Hash, Numeric, nil]
|
|
28
|
+
# @return [Amount, nil]
|
|
29
|
+
# @raise [Amount::InvalidInput] when the value cannot be cast
|
|
30
|
+
# @raise [Amount::TypeMismatch] when a fixed-symbol assignment uses a
|
|
31
|
+
# different symbol
|
|
32
|
+
# @example
|
|
33
|
+
# type = Amount::ActiveRecord::Type.new
|
|
34
|
+
# type.cast("USDC|1.50")
|
|
35
|
+
def cast(value)
|
|
36
|
+
return nil if value.nil? || value == ""
|
|
37
|
+
|
|
38
|
+
amount = case value
|
|
39
|
+
when ::Amount then value
|
|
40
|
+
when String then ::Amount.parse(value)
|
|
41
|
+
when Hash then cast_hash(value)
|
|
42
|
+
when Integer, Float, BigDecimal, Rational then cast_numeric(value)
|
|
43
|
+
else
|
|
44
|
+
raise ::Amount::InvalidInput, "cannot cast #{value.class} to Amount"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
ensure_fixed_symbol!(amount) if fixed_symbol
|
|
48
|
+
amount
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param atomic [Integer, String, BigDecimal]
|
|
52
|
+
# @param symbol [Symbol, String, nil]
|
|
53
|
+
# @return [Amount, nil]
|
|
54
|
+
# @example
|
|
55
|
+
# Amount::ActiveRecord::Type.new.deserialize("1500000", :USDC)
|
|
56
|
+
def deserialize(atomic, symbol = fixed_symbol)
|
|
57
|
+
return nil if atomic.nil?
|
|
58
|
+
|
|
59
|
+
resolved_symbol = (symbol || fixed_symbol)
|
|
60
|
+
return nil if resolved_symbol.nil?
|
|
61
|
+
|
|
62
|
+
::Amount.new(atomic.to_i, resolved_symbol, from: :atomic)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param value [Amount, String, Hash, Numeric, nil]
|
|
66
|
+
# @return [Hash, nil]
|
|
67
|
+
# @example
|
|
68
|
+
# Amount::ActiveRecord::Type.new.dump("USDC|1.50")
|
|
69
|
+
# # => { atomic: 1500000, symbol: :USDC }
|
|
70
|
+
def dump(value)
|
|
71
|
+
amount = cast(value)
|
|
72
|
+
return nil unless amount
|
|
73
|
+
|
|
74
|
+
{ atomic: amount.atomic, symbol: amount.symbol }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def cast_hash(value)
|
|
80
|
+
if value.key?(:atomic) || value.key?("atomic")
|
|
81
|
+
::Amount.load(value)
|
|
82
|
+
else
|
|
83
|
+
symbol = value.fetch(:symbol) { value.fetch("symbol", fixed_symbol) }
|
|
84
|
+
amount_value = value.fetch(:value) { value.fetch("value") }
|
|
85
|
+
::Amount.new(amount_value, symbol)
|
|
86
|
+
end
|
|
87
|
+
rescue KeyError
|
|
88
|
+
raise ::Amount::InvalidInput, "hash input must contain atomic/symbol or value/symbol"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cast_numeric(value)
|
|
92
|
+
unless fixed_symbol
|
|
93
|
+
raise ::Amount::InvalidInput, "raw numeric assignment requires a fixed symbol"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
::Amount.new(value, fixed_symbol, from: :float)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ensure_fixed_symbol!(amount)
|
|
100
|
+
return if amount.symbol == fixed_symbol
|
|
101
|
+
|
|
102
|
+
raise ::Amount::TypeMismatch, "expected #{fixed_symbol}, got #{amount.symbol}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
require_relative "../amount"
|
|
6
|
+
require_relative "active_record/attribute_definition"
|
|
7
|
+
require_relative "active_record/amount_validator"
|
|
8
|
+
require_relative "active_record/type"
|
|
9
|
+
require_relative "active_record/migration_methods"
|
|
10
|
+
require_relative "active_record/model"
|
|
11
|
+
|
|
12
|
+
class Amount
|
|
13
|
+
# Optional Rails integration for ActiveRecord models and migrations.
|
|
14
|
+
#
|
|
15
|
+
# This file is intentionally opt-in. Requiring `"amount/active_record"`
|
|
16
|
+
# extends ActiveRecord table definitions with `t.amount` and models with
|
|
17
|
+
# `has_amount`.
|
|
18
|
+
#
|
|
19
|
+
# @example Loading the integration in a Rails app
|
|
20
|
+
# require "amount/active_record"
|
|
21
|
+
#
|
|
22
|
+
# class Holding < ApplicationRecord
|
|
23
|
+
# has_amount :amount
|
|
24
|
+
# has_amount :fee, symbol: :SOL
|
|
25
|
+
# end
|
|
26
|
+
module ActiveRecord
|
|
27
|
+
# Installs the migration DSL and model macros into ActiveRecord.
|
|
28
|
+
#
|
|
29
|
+
# This is called automatically when the file is required.
|
|
30
|
+
#
|
|
31
|
+
# @return [void]
|
|
32
|
+
def self.install!
|
|
33
|
+
::ActiveRecord::ConnectionAdapters::TableDefinition.include(MigrationMethods)
|
|
34
|
+
if defined?(::ActiveRecord::ConnectionAdapters::Table)
|
|
35
|
+
::ActiveRecord::ConnectionAdapters::Table.include(MigrationMethods)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
::ActiveRecord::Base.extend(Model)
|
|
39
|
+
::ActiveRecord::Base.include(InstanceMethods)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Amount::ActiveRecord.install!
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
class Amount
|
|
6
|
+
# Formats amounts for UI output without changing their type.
|
|
7
|
+
class Display
|
|
8
|
+
# @param amount [Amount]
|
|
9
|
+
def initialize(amount)
|
|
10
|
+
@amount = amount
|
|
11
|
+
@entry = amount.registry_entry
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [String]
|
|
15
|
+
def formatted
|
|
16
|
+
format("%.#{@entry.decimals}f", @amount.decimal)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param unit [Symbol, nil]
|
|
20
|
+
# @param direction [Symbol]
|
|
21
|
+
# @return [String]
|
|
22
|
+
def ui(unit: nil, direction: :floor)
|
|
23
|
+
unit ? render_display_unit(unit, direction) : render_default(direction)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String]
|
|
27
|
+
def to_s
|
|
28
|
+
"#{@entry.symbol}|#{@amount.decimal.to_s("F")}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param unit [Symbol]
|
|
32
|
+
# @return [BigDecimal]
|
|
33
|
+
def in_unit(unit)
|
|
34
|
+
unit_spec = fetch_display_unit(unit)
|
|
35
|
+
@amount.decimal * BigDecimal(unit_spec[:scale].to_s)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def render_default(direction)
|
|
41
|
+
rounded = round(@amount.decimal, @entry.ui_decimals, direction)
|
|
42
|
+
apply_symbol(format("%.#{@entry.ui_decimals}f", rounded), @entry.display_symbol, @entry.display_position)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def render_display_unit(unit, direction)
|
|
46
|
+
spec = fetch_display_unit(unit)
|
|
47
|
+
scaled = @amount.decimal * BigDecimal(spec[:scale].to_s)
|
|
48
|
+
decimals = spec[:ui_decimals] || @entry.ui_decimals
|
|
49
|
+
rounded = round(scaled, decimals, direction)
|
|
50
|
+
|
|
51
|
+
apply_symbol(
|
|
52
|
+
format("%.#{decimals}f", rounded),
|
|
53
|
+
spec[:symbol] || @entry.display_symbol,
|
|
54
|
+
spec[:position] || @entry.display_position
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fetch_display_unit(unit)
|
|
59
|
+
units = @entry.display_units
|
|
60
|
+
unless units
|
|
61
|
+
raise Registry::InvalidDisplayUnit, "#{@entry.symbol} has no display_units configured"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
units.fetch(unit.to_sym) do
|
|
65
|
+
raise Registry::InvalidDisplayUnit, "unknown display unit #{unit} for #{@entry.symbol}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def round(value, decimals, direction)
|
|
70
|
+
factor = BigDecimal(10)**decimals
|
|
71
|
+
scaled = value * factor
|
|
72
|
+
truncated = direction == :ceil ? scaled.ceil : scaled.floor
|
|
73
|
+
truncated / factor
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def apply_symbol(str, symbol, position)
|
|
77
|
+
return str if symbol.nil? || symbol.empty?
|
|
78
|
+
|
|
79
|
+
position == :prefix ? "#{symbol}#{str}" : "#{str} #{symbol}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Parses compact amount strings such as `USDC|1.50`.
|
|
5
|
+
class Parser
|
|
6
|
+
VERSION_PREFIX = /\A(v\d+):(.*)\z/
|
|
7
|
+
SUPPORTED_VERSION = "v1"
|
|
8
|
+
|
|
9
|
+
def initialize(input)
|
|
10
|
+
@input = input
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def parse
|
|
14
|
+
symbol, value = parse_components
|
|
15
|
+
validate!(symbol, value)
|
|
16
|
+
|
|
17
|
+
Amount.new(value, symbol)
|
|
18
|
+
rescue ArgumentError
|
|
19
|
+
raise InvalidInput, "cannot parse #{@input.inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def parse_components
|
|
25
|
+
compact = @input.to_s
|
|
26
|
+
versioned = compact.match(VERSION_PREFIX)
|
|
27
|
+
return split_components(compact) unless versioned
|
|
28
|
+
|
|
29
|
+
version, body = versioned.captures
|
|
30
|
+
validate_version!(version)
|
|
31
|
+
split_components(body)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def split_components(compact)
|
|
35
|
+
compact.split("|", 2)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_version!(version)
|
|
39
|
+
return if version == SUPPORTED_VERSION
|
|
40
|
+
|
|
41
|
+
raise InvalidInput, "cannot parse #{@input.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate!(symbol, value)
|
|
45
|
+
return unless symbol.nil? || value.nil? || symbol.empty? || value.empty?
|
|
46
|
+
|
|
47
|
+
raise InvalidInput, "cannot parse #{@input.inspect}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|