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.
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ class Railtie < Rails::Railtie
5
+ railtie_name :wallets
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,3 @@
1
+ module Wallets
2
+ VERSION: String
3
+ end