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,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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../active_record"
4
+ require "rspec/expectations"
5
+ require_relative "../rspec"
6
+
7
+ Amount::RSpecMatchers.define_amount_column_matcher
8
+ Amount::RSpecMatchers.define_amount_sum_matcher
@@ -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