wallets 0.1.0
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/.gitignore +36 -0
- data/.rubocop.yml +8 -0
- data/.simplecov +37 -0
- data/AGENTS.md +5 -0
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +740 -0
- data/Rakefile +30 -0
- data/lib/generators/wallets/install_generator.rb +43 -0
- data/lib/generators/wallets/templates/create_wallets_tables.rb.erb +113 -0
- data/lib/generators/wallets/templates/initializer.rb +87 -0
- data/lib/wallets/callback_context.rb +28 -0
- data/lib/wallets/callbacks.rb +53 -0
- data/lib/wallets/configuration.rb +119 -0
- data/lib/wallets/engine.rb +23 -0
- data/lib/wallets/models/allocation.rb +47 -0
- data/lib/wallets/models/concerns/has_wallets.rb +93 -0
- data/lib/wallets/models/transaction.rb +154 -0
- data/lib/wallets/models/transfer.rb +122 -0
- data/lib/wallets/models/wallet.rb +654 -0
- data/lib/wallets/railtie.rb +7 -0
- data/lib/wallets/version.rb +5 -0
- data/lib/wallets.rb +45 -0
- data/sig/wallets.rbs +3 -0
- data/wallets.webp +0 -0
- metadata +96 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wallets
|
|
4
|
+
# Wallet is the owner-facing API for one asset balance.
|
|
5
|
+
#
|
|
6
|
+
# Balances are not maintained by incrementing a counter in place. Instead, the
|
|
7
|
+
# wallet derives its current balance from transactions and allocations so it
|
|
8
|
+
# can support FIFO consumption, expirations, transfers, and a durable audit
|
|
9
|
+
# trail at the same time.
|
|
10
|
+
#
|
|
11
|
+
# This class supports embedding: subclasses can override config, callbacks,
|
|
12
|
+
# table names, and related model classes without affecting the base Wallets::*
|
|
13
|
+
# behavior in the same application.
|
|
14
|
+
class Wallet < ApplicationRecord
|
|
15
|
+
# =========================================
|
|
16
|
+
# Embeddability Hooks
|
|
17
|
+
# =========================================
|
|
18
|
+
|
|
19
|
+
class_attribute :embedded_table_name, default: nil
|
|
20
|
+
class_attribute :config_provider, default: -> { Wallets.configuration }
|
|
21
|
+
class_attribute :callbacks_module, default: Wallets::Callbacks
|
|
22
|
+
class_attribute :transaction_class_name, default: "Wallets::Transaction"
|
|
23
|
+
class_attribute :allocation_class_name, default: "Wallets::Allocation"
|
|
24
|
+
class_attribute :transfer_class_name, default: "Wallets::Transfer"
|
|
25
|
+
class_attribute :callback_event_map, default: {
|
|
26
|
+
credited: :balance_credited,
|
|
27
|
+
debited: :balance_debited,
|
|
28
|
+
insufficient: :insufficient_balance,
|
|
29
|
+
low_balance: :low_balance_reached,
|
|
30
|
+
depleted: :balance_depleted,
|
|
31
|
+
transfer_completed: :transfer_completed
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# =========================================
|
|
35
|
+
# Table Name Resolution
|
|
36
|
+
# =========================================
|
|
37
|
+
|
|
38
|
+
def self.table_name
|
|
39
|
+
embedded_table_name || "#{resolved_config.table_prefix}wallets"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.resolved_config
|
|
43
|
+
value = config_provider
|
|
44
|
+
value.respond_to?(:call) ? value.call : value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.transaction_class
|
|
48
|
+
transaction_class_name.constantize
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.allocation_class
|
|
52
|
+
allocation_class_name.constantize
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.transfer_class
|
|
56
|
+
transfer_class_name.constantize
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# =========================================
|
|
60
|
+
# Associations & Validations
|
|
61
|
+
# =========================================
|
|
62
|
+
|
|
63
|
+
belongs_to :owner, polymorphic: true
|
|
64
|
+
|
|
65
|
+
has_many :transactions, class_name: "Wallets::Transaction", dependent: :destroy
|
|
66
|
+
has_many :outgoing_transfers,
|
|
67
|
+
class_name: "Wallets::Transfer",
|
|
68
|
+
foreign_key: :from_wallet_id,
|
|
69
|
+
dependent: :destroy,
|
|
70
|
+
inverse_of: :from_wallet
|
|
71
|
+
has_many :incoming_transfers,
|
|
72
|
+
class_name: "Wallets::Transfer",
|
|
73
|
+
foreign_key: :to_wallet_id,
|
|
74
|
+
dependent: :destroy,
|
|
75
|
+
inverse_of: :to_wallet
|
|
76
|
+
|
|
77
|
+
validates :asset_code, presence: true, uniqueness: { scope: [:owner_type, :owner_id] }
|
|
78
|
+
validates :balance, numericality: { only_integer: true }
|
|
79
|
+
validates :balance, numericality: { greater_than_or_equal_to: 0 }, unless: :allow_negative_balance?
|
|
80
|
+
|
|
81
|
+
before_validation :normalize_asset_code!
|
|
82
|
+
before_save :sync_metadata_cache
|
|
83
|
+
|
|
84
|
+
# =========================================
|
|
85
|
+
# Class Methods
|
|
86
|
+
# =========================================
|
|
87
|
+
|
|
88
|
+
class << self
|
|
89
|
+
def create_for_owner!(owner:, asset_code:, initial_balance: 0, metadata: {})
|
|
90
|
+
initial_balance = normalize_initial_balance(initial_balance)
|
|
91
|
+
asset_code = normalize_asset_code(asset_code)
|
|
92
|
+
metadata = metadata.respond_to?(:to_h) ? metadata.to_h : {}
|
|
93
|
+
|
|
94
|
+
existing_wallet = find_by(owner: owner, asset_code: asset_code)
|
|
95
|
+
return existing_wallet if existing_wallet.present?
|
|
96
|
+
|
|
97
|
+
transaction do
|
|
98
|
+
wallet = create!(
|
|
99
|
+
owner: owner,
|
|
100
|
+
asset_code: asset_code,
|
|
101
|
+
balance: 0,
|
|
102
|
+
metadata: metadata
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if initial_balance.positive?
|
|
106
|
+
wallet.credit(initial_balance, **initial_balance_credit_attributes)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
wallet
|
|
110
|
+
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
|
|
111
|
+
wallet = find_by(owner: owner, asset_code: asset_code)
|
|
112
|
+
raise error if wallet.nil?
|
|
113
|
+
|
|
114
|
+
if record_conflict_due_to_existing_wallet?(error)
|
|
115
|
+
wallet
|
|
116
|
+
else
|
|
117
|
+
raise error
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def initial_balance_credit_attributes
|
|
125
|
+
{
|
|
126
|
+
category: :adjustment,
|
|
127
|
+
metadata: { reason: "initial_balance" }
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def normalize_initial_balance(value)
|
|
132
|
+
return 0 if value.nil?
|
|
133
|
+
raise ArgumentError, "Initial balance must be a whole number" unless value == value.to_i
|
|
134
|
+
|
|
135
|
+
value = value.to_i
|
|
136
|
+
raise ArgumentError, "Initial balance cannot be negative" if value.negative?
|
|
137
|
+
|
|
138
|
+
value
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def normalize_asset_code(value)
|
|
142
|
+
value.to_s.strip.downcase.presence || raise(ArgumentError, "Asset code is required")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def record_conflict_due_to_existing_wallet?(error)
|
|
146
|
+
return true if error.is_a?(ActiveRecord::RecordNotUnique)
|
|
147
|
+
return false unless error.is_a?(ActiveRecord::RecordInvalid)
|
|
148
|
+
|
|
149
|
+
error.record.errors.of_kind?(:asset_code, :taken)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# =========================================
|
|
154
|
+
# Metadata Handling
|
|
155
|
+
# =========================================
|
|
156
|
+
|
|
157
|
+
def metadata
|
|
158
|
+
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def metadata=(hash)
|
|
162
|
+
@indifferent_metadata = nil
|
|
163
|
+
super(hash.respond_to?(:to_h) ? hash.to_h : {})
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def reload(*)
|
|
167
|
+
@indifferent_metadata = nil
|
|
168
|
+
super
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# =========================================
|
|
172
|
+
# Balance & History
|
|
173
|
+
# =========================================
|
|
174
|
+
|
|
175
|
+
def balance
|
|
176
|
+
current_balance
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def current_balance
|
|
180
|
+
positive_remaining_balance - unbacked_negative_balance
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def history
|
|
184
|
+
transactions.order(created_at: :asc)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def has_enough_balance?(amount)
|
|
188
|
+
normalized_amount = normalize_positive_amount!(amount)
|
|
189
|
+
balance >= normalized_amount
|
|
190
|
+
rescue ArgumentError
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# =========================================
|
|
195
|
+
# Credit & Debit Operations
|
|
196
|
+
# =========================================
|
|
197
|
+
|
|
198
|
+
def credit(amount, metadata: {}, category: :credit, expires_at: nil, transfer: nil, **extra_transaction_attributes)
|
|
199
|
+
metadata = normalize_metadata(metadata)
|
|
200
|
+
|
|
201
|
+
with_lock do
|
|
202
|
+
apply_credit(
|
|
203
|
+
amount,
|
|
204
|
+
metadata: metadata,
|
|
205
|
+
category: category,
|
|
206
|
+
expires_at: expires_at,
|
|
207
|
+
transfer: transfer,
|
|
208
|
+
extra_attributes: extra_transaction_attributes
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def debit(amount, metadata: {}, category: :debit, transfer: nil, **extra_transaction_attributes)
|
|
214
|
+
metadata = normalize_metadata(metadata)
|
|
215
|
+
|
|
216
|
+
with_lock do
|
|
217
|
+
apply_debit(
|
|
218
|
+
amount,
|
|
219
|
+
metadata: metadata,
|
|
220
|
+
category: category,
|
|
221
|
+
transfer: transfer,
|
|
222
|
+
extra_attributes: extra_transaction_attributes
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# =========================================
|
|
228
|
+
# Transfers
|
|
229
|
+
# =========================================
|
|
230
|
+
|
|
231
|
+
def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expiration_policy: nil, expires_at: nil)
|
|
232
|
+
raise InvalidTransfer, "Target wallet is required" if other_wallet.nil?
|
|
233
|
+
raise InvalidTransfer, "Target wallet must be persisted" unless other_wallet.persisted?
|
|
234
|
+
raise InvalidTransfer, "Cannot transfer to the same wallet" if other_wallet.id == id
|
|
235
|
+
raise InvalidTransfer, "Wallet assets must match" unless asset_code == other_wallet.asset_code
|
|
236
|
+
raise InvalidTransfer, "Wallet classes must match" unless other_wallet.class == self.class
|
|
237
|
+
|
|
238
|
+
amount = normalize_positive_amount!(amount)
|
|
239
|
+
metadata = normalize_metadata(metadata)
|
|
240
|
+
resolved_policy, inbound_expires_at = resolve_transfer_expiration!(expiration_policy, expires_at)
|
|
241
|
+
|
|
242
|
+
ActiveRecord::Base.transaction do
|
|
243
|
+
lock_wallet_pair!(other_wallet)
|
|
244
|
+
|
|
245
|
+
previous_balance = balance
|
|
246
|
+
if amount > previous_balance
|
|
247
|
+
dispatch_insufficient_balance!(amount, previous_balance, metadata)
|
|
248
|
+
raise InsufficientBalance, "Insufficient balance (#{previous_balance} < #{amount})"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
transfer = transfer_class.create!(
|
|
252
|
+
from_wallet: self,
|
|
253
|
+
to_wallet: other_wallet,
|
|
254
|
+
asset_code: asset_code,
|
|
255
|
+
amount: amount,
|
|
256
|
+
category: category,
|
|
257
|
+
expiration_policy: resolved_policy,
|
|
258
|
+
metadata: metadata
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
shared_metadata = metadata.to_h.deep_stringify_keys.merge(
|
|
262
|
+
"transfer_id" => transfer.id,
|
|
263
|
+
"asset_code" => asset_code,
|
|
264
|
+
"transfer_category" => category.to_s,
|
|
265
|
+
"transfer_expiration_policy" => resolved_policy
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
outbound_transaction = apply_debit(
|
|
269
|
+
amount,
|
|
270
|
+
category: :transfer_out,
|
|
271
|
+
metadata: shared_metadata.merge(
|
|
272
|
+
"counterparty_wallet_id" => other_wallet.id,
|
|
273
|
+
"counterparty_owner_id" => other_wallet.owner_id,
|
|
274
|
+
"counterparty_owner_type" => other_wallet.owner_type
|
|
275
|
+
),
|
|
276
|
+
transfer: transfer
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
build_transfer_inbound_credit_specs(
|
|
280
|
+
transfer: transfer,
|
|
281
|
+
outbound_transaction: outbound_transaction,
|
|
282
|
+
amount: amount,
|
|
283
|
+
expiration_policy: resolved_policy,
|
|
284
|
+
expires_at: inbound_expires_at
|
|
285
|
+
).each do |spec|
|
|
286
|
+
other_wallet.send(
|
|
287
|
+
:apply_credit,
|
|
288
|
+
spec[:amount],
|
|
289
|
+
category: :transfer_in,
|
|
290
|
+
metadata: shared_metadata.merge(
|
|
291
|
+
"counterparty_wallet_id" => id,
|
|
292
|
+
"counterparty_owner_id" => owner_id,
|
|
293
|
+
"counterparty_owner_type" => owner_type
|
|
294
|
+
),
|
|
295
|
+
expires_at: spec[:expires_at],
|
|
296
|
+
transfer: transfer
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
dispatch_callback(:transfer_completed,
|
|
301
|
+
wallet: self,
|
|
302
|
+
transfer: transfer,
|
|
303
|
+
amount: amount,
|
|
304
|
+
category: category,
|
|
305
|
+
metadata: metadata
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
transfer
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
private
|
|
313
|
+
|
|
314
|
+
# =========================================
|
|
315
|
+
# Config & Class Accessors (Instance)
|
|
316
|
+
# =========================================
|
|
317
|
+
|
|
318
|
+
def config
|
|
319
|
+
self.class.resolved_config
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def callbacks
|
|
323
|
+
self.class.callbacks_module
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def transaction_class
|
|
327
|
+
self.class.transaction_class
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def allocation_class
|
|
331
|
+
self.class.allocation_class
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def transfer_class
|
|
335
|
+
self.class.transfer_class
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# =========================================
|
|
339
|
+
# Callback Dispatching
|
|
340
|
+
# =========================================
|
|
341
|
+
|
|
342
|
+
def dispatch_callback(kind, **data)
|
|
343
|
+
event = self.class.callback_event_map[kind]
|
|
344
|
+
return if event.nil?
|
|
345
|
+
|
|
346
|
+
callbacks.dispatch(event, **data)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# =========================================
|
|
350
|
+
# Credit/Debit Implementation
|
|
351
|
+
# =========================================
|
|
352
|
+
|
|
353
|
+
def apply_credit(amount, metadata:, category:, expires_at:, transfer:, extra_attributes: {})
|
|
354
|
+
amount = normalize_positive_amount!(amount)
|
|
355
|
+
validate_expiration!(expires_at)
|
|
356
|
+
|
|
357
|
+
previous_balance = balance
|
|
358
|
+
|
|
359
|
+
transaction = transactions.create!(
|
|
360
|
+
{
|
|
361
|
+
amount: amount,
|
|
362
|
+
category: category,
|
|
363
|
+
expires_at: expires_at,
|
|
364
|
+
metadata: metadata,
|
|
365
|
+
transfer: transfer
|
|
366
|
+
}.merge(extra_attributes)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
refresh_cached_balance!
|
|
370
|
+
transaction.sync_balance_snapshot!(before: previous_balance, after: balance)
|
|
371
|
+
|
|
372
|
+
dispatch_callback(:credited,
|
|
373
|
+
wallet: self,
|
|
374
|
+
amount: amount,
|
|
375
|
+
category: category,
|
|
376
|
+
transaction: transaction,
|
|
377
|
+
previous_balance: previous_balance,
|
|
378
|
+
new_balance: balance,
|
|
379
|
+
metadata: metadata
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
transaction
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def apply_debit(amount, metadata:, category:, transfer:, extra_attributes: {})
|
|
386
|
+
amount = normalize_positive_amount!(amount)
|
|
387
|
+
previous_balance = balance
|
|
388
|
+
|
|
389
|
+
if amount > previous_balance && !allow_negative_balance?
|
|
390
|
+
dispatch_insufficient_balance!(amount, previous_balance, metadata)
|
|
391
|
+
raise InsufficientBalance, "Insufficient balance (#{previous_balance} < #{amount})"
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
spend_transaction = transactions.create!(
|
|
395
|
+
{
|
|
396
|
+
amount: -amount,
|
|
397
|
+
category: category,
|
|
398
|
+
metadata: metadata,
|
|
399
|
+
transfer: transfer
|
|
400
|
+
}.merge(extra_attributes)
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
remaining_to_allocate = allocate_debit!(spend_transaction, amount)
|
|
404
|
+
|
|
405
|
+
if remaining_to_allocate.positive? && !allow_negative_balance?
|
|
406
|
+
raise InsufficientBalance, "Not enough balance buckets to cover the debit"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
refresh_cached_balance!
|
|
410
|
+
spend_transaction.sync_balance_snapshot!(before: previous_balance, after: balance)
|
|
411
|
+
|
|
412
|
+
dispatch_callback(:debited,
|
|
413
|
+
wallet: self,
|
|
414
|
+
amount: amount,
|
|
415
|
+
category: category,
|
|
416
|
+
transaction: spend_transaction,
|
|
417
|
+
previous_balance: previous_balance,
|
|
418
|
+
new_balance: balance,
|
|
419
|
+
metadata: metadata
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
dispatch_balance_threshold_callbacks!(previous_balance)
|
|
423
|
+
|
|
424
|
+
spend_transaction
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def allocate_debit!(spend_transaction, amount)
|
|
428
|
+
remaining_to_allocate = amount
|
|
429
|
+
|
|
430
|
+
positive_transactions = transactions
|
|
431
|
+
.where("amount > 0")
|
|
432
|
+
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
|
433
|
+
.order(Arel.sql("COALESCE(expires_at, '9999-12-31 23:59:59'), id ASC"))
|
|
434
|
+
.lock("FOR UPDATE")
|
|
435
|
+
.to_a
|
|
436
|
+
|
|
437
|
+
positive_transactions.each do |source_transaction|
|
|
438
|
+
leftover = source_transaction.remaining_amount
|
|
439
|
+
next if leftover <= 0
|
|
440
|
+
|
|
441
|
+
allocation_amount = [leftover, remaining_to_allocate].min
|
|
442
|
+
|
|
443
|
+
allocation_class.create!(
|
|
444
|
+
spend_transaction: spend_transaction,
|
|
445
|
+
source_transaction: source_transaction,
|
|
446
|
+
amount: allocation_amount
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
remaining_to_allocate -= allocation_amount
|
|
450
|
+
break if remaining_to_allocate <= 0
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
remaining_to_allocate
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# =========================================
|
|
457
|
+
# Transfer Expiration Handling
|
|
458
|
+
# =========================================
|
|
459
|
+
|
|
460
|
+
def resolve_transfer_expiration!(expiration_policy, expires_at)
|
|
461
|
+
default_policy =
|
|
462
|
+
if config.respond_to?(:transfer_expiration_policy)
|
|
463
|
+
config.transfer_expiration_policy
|
|
464
|
+
else
|
|
465
|
+
:preserve
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
policy =
|
|
469
|
+
if expires_at.present? && expiration_policy.nil?
|
|
470
|
+
"fixed"
|
|
471
|
+
else
|
|
472
|
+
normalize_transfer_expiration_policy(expiration_policy || default_policy)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
case policy
|
|
476
|
+
when "preserve", "none"
|
|
477
|
+
raise ArgumentError, "expires_at cannot be combined with #{policy} transfer expiration policy" if expires_at.present?
|
|
478
|
+
[policy, nil]
|
|
479
|
+
when "fixed"
|
|
480
|
+
raise ArgumentError, "expires_at is required when using a fixed transfer expiration policy" if expires_at.nil?
|
|
481
|
+
|
|
482
|
+
validate_expiration!(expires_at)
|
|
483
|
+
[policy, expires_at]
|
|
484
|
+
else
|
|
485
|
+
raise ArgumentError, "Unsupported transfer expiration policy: #{policy}"
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def normalize_transfer_expiration_policy(value)
|
|
490
|
+
value.to_s.strip.downcase
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def build_transfer_inbound_credit_specs(transfer:, outbound_transaction:, amount:, expiration_policy:, expires_at:)
|
|
494
|
+
case expiration_policy
|
|
495
|
+
when "none"
|
|
496
|
+
[{ amount: amount, expires_at: nil }]
|
|
497
|
+
when "fixed"
|
|
498
|
+
[{ amount: amount, expires_at: expires_at }]
|
|
499
|
+
when "preserve"
|
|
500
|
+
build_preserved_transfer_inbound_credit_specs(transfer, outbound_transaction, amount)
|
|
501
|
+
else
|
|
502
|
+
raise ArgumentError, "Unsupported transfer expiration policy: #{expiration_policy}"
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def build_preserved_transfer_inbound_credit_specs(transfer, outbound_transaction, amount)
|
|
507
|
+
allocations = outbound_transaction.outgoing_allocations.includes(:source_transaction).order(:id).to_a
|
|
508
|
+
grouped_specs = []
|
|
509
|
+
|
|
510
|
+
allocations.each do |allocation|
|
|
511
|
+
expires_at = allocation.source_transaction.expires_at
|
|
512
|
+
|
|
513
|
+
if grouped_specs.last && grouped_specs.last[:expires_at] == expires_at
|
|
514
|
+
grouped_specs.last[:amount] += allocation.amount
|
|
515
|
+
else
|
|
516
|
+
grouped_specs << { amount: allocation.amount, expires_at: expires_at }
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
total_preserved_amount = grouped_specs.sum { |spec| spec[:amount] }
|
|
521
|
+
if total_preserved_amount != amount
|
|
522
|
+
raise InvalidTransfer, "Transfer #{transfer.id} could not preserve expiration buckets (#{total_preserved_amount} != #{amount})"
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
grouped_specs
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# =========================================
|
|
529
|
+
# Balance Calculation
|
|
530
|
+
# =========================================
|
|
531
|
+
|
|
532
|
+
def positive_remaining_balance
|
|
533
|
+
txn_table = transaction_class.table_name
|
|
534
|
+
alloc_table = allocation_class.table_name
|
|
535
|
+
|
|
536
|
+
transactions
|
|
537
|
+
.where("amount > 0")
|
|
538
|
+
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
|
539
|
+
.sum("amount - (SELECT COALESCE(SUM(amount), 0) FROM #{alloc_table} WHERE source_transaction_id = #{txn_table}.id)")
|
|
540
|
+
.to_i
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def unbacked_negative_balance
|
|
544
|
+
txn_table = transaction_class.table_name
|
|
545
|
+
alloc_table = allocation_class.table_name
|
|
546
|
+
|
|
547
|
+
transactions
|
|
548
|
+
.where("amount < 0")
|
|
549
|
+
.sum("ABS(amount) - (SELECT COALESCE(SUM(amount), 0) FROM #{alloc_table} WHERE transaction_id = #{txn_table}.id)")
|
|
550
|
+
.to_i
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def refresh_cached_balance!
|
|
554
|
+
write_attribute(:balance, current_balance)
|
|
555
|
+
save!
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# =========================================
|
|
559
|
+
# Threshold Callbacks
|
|
560
|
+
# =========================================
|
|
561
|
+
|
|
562
|
+
def dispatch_insufficient_balance!(amount, previous_balance, metadata)
|
|
563
|
+
dispatch_callback(:insufficient,
|
|
564
|
+
wallet: self,
|
|
565
|
+
amount: amount,
|
|
566
|
+
previous_balance: previous_balance,
|
|
567
|
+
new_balance: previous_balance,
|
|
568
|
+
metadata: metadata.merge(
|
|
569
|
+
available: previous_balance,
|
|
570
|
+
required: amount
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def dispatch_balance_threshold_callbacks!(previous_balance)
|
|
576
|
+
if !was_low_balance?(previous_balance) && low_balance?
|
|
577
|
+
dispatch_callback(:low_balance,
|
|
578
|
+
wallet: self,
|
|
579
|
+
threshold: config.low_balance_threshold,
|
|
580
|
+
previous_balance: previous_balance,
|
|
581
|
+
new_balance: balance
|
|
582
|
+
)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
if previous_balance.positive? && balance.zero?
|
|
586
|
+
dispatch_callback(:depleted,
|
|
587
|
+
wallet: self,
|
|
588
|
+
previous_balance: previous_balance,
|
|
589
|
+
new_balance: 0
|
|
590
|
+
)
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def low_balance?
|
|
595
|
+
threshold = config.low_balance_threshold
|
|
596
|
+
return false if threshold.nil?
|
|
597
|
+
|
|
598
|
+
balance <= threshold
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def was_low_balance?(previous_balance)
|
|
602
|
+
threshold = config.low_balance_threshold
|
|
603
|
+
return false if threshold.nil?
|
|
604
|
+
|
|
605
|
+
previous_balance <= threshold
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def allow_negative_balance?
|
|
609
|
+
config.allow_negative_balance
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# =========================================
|
|
613
|
+
# Validation Helpers
|
|
614
|
+
# =========================================
|
|
615
|
+
|
|
616
|
+
def validate_expiration!(expires_at)
|
|
617
|
+
return if expires_at.nil?
|
|
618
|
+
raise ArgumentError, "Expiration date must respond to to_datetime" unless expires_at.respond_to?(:to_datetime)
|
|
619
|
+
raise ArgumentError, "Expiration date must be in the future" if expires_at <= Time.current
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def normalize_positive_amount!(amount)
|
|
623
|
+
raise ArgumentError, "Amount is required" if amount.nil?
|
|
624
|
+
raise ArgumentError, "Amount must be a whole number" unless amount == amount.to_i
|
|
625
|
+
|
|
626
|
+
amount = amount.to_i
|
|
627
|
+
raise ArgumentError, "Amount must be positive" unless amount.positive?
|
|
628
|
+
|
|
629
|
+
amount
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def normalize_metadata(metadata)
|
|
633
|
+
metadata.respond_to?(:to_h) ? metadata.to_h : {}
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def lock_wallet_pair!(other_wallet)
|
|
637
|
+
first, second = [self, other_wallet].sort_by(&:id)
|
|
638
|
+
first.lock!
|
|
639
|
+
second.lock! unless first.id == second.id
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def normalize_asset_code!
|
|
643
|
+
self.asset_code = asset_code.to_s.strip.downcase.presence
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def sync_metadata_cache
|
|
647
|
+
if @indifferent_metadata
|
|
648
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
649
|
+
elsif read_attribute(:metadata).nil?
|
|
650
|
+
write_attribute(:metadata, {})
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
end
|
data/lib/wallets.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require "active_record"
|
|
5
|
+
require "active_support/all"
|
|
6
|
+
|
|
7
|
+
require "wallets/version"
|
|
8
|
+
require "wallets/configuration"
|
|
9
|
+
require "wallets/callback_context"
|
|
10
|
+
require "wallets/callbacks"
|
|
11
|
+
|
|
12
|
+
module Wallets
|
|
13
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
14
|
+
self.abstract_class = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
class InsufficientBalance < Error; end
|
|
19
|
+
class InvalidTransfer < Error; end
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
attr_writer :configuration
|
|
23
|
+
|
|
24
|
+
def configuration
|
|
25
|
+
@configuration ||= Configuration.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def configure
|
|
29
|
+
yield(configuration)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reset!
|
|
33
|
+
@configuration = nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
require "wallets/models/concerns/has_wallets"
|
|
39
|
+
require "wallets/models/wallet"
|
|
40
|
+
require "wallets/models/transaction"
|
|
41
|
+
require "wallets/models/allocation"
|
|
42
|
+
require "wallets/models/transfer"
|
|
43
|
+
|
|
44
|
+
require "wallets/engine" if defined?(Rails)
|
|
45
|
+
require "wallets/railtie" if defined?(Rails)
|
data/sig/wallets.rbs
ADDED