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,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ # Transactions are the append-only source of truth for wallet balance changes.
5
+ # Positive rows add value, negative rows consume value, and transfers link both
6
+ # sides of an internal movement through `transfer_id`.
7
+ #
8
+ # This class supports embedding: subclasses can override config and table
9
+ # names without affecting the base Wallets::* behavior.
10
+ class Transaction < ApplicationRecord
11
+ class_attribute :embedded_table_name, default: nil
12
+ class_attribute :config_provider, default: -> { Wallets.configuration }
13
+
14
+ def self.table_name
15
+ embedded_table_name || "#{resolved_config.table_prefix}transactions"
16
+ end
17
+
18
+ def self.resolved_config
19
+ value = config_provider
20
+ value.respond_to?(:call) ? value.call : value
21
+ end
22
+
23
+ DEFAULT_CATEGORIES = [
24
+ "credit",
25
+ "debit",
26
+ "transfer_in",
27
+ "transfer_out",
28
+ "refund",
29
+ "reward",
30
+ "purchase",
31
+ "top_up",
32
+ "adjustment"
33
+ ].freeze
34
+ CATEGORIES = DEFAULT_CATEGORIES
35
+
36
+ def self.categories
37
+ extra_categories =
38
+ if resolved_config.respond_to?(:additional_categories)
39
+ resolved_config.additional_categories
40
+ else
41
+ []
42
+ end
43
+
44
+ (DEFAULT_CATEGORIES + extra_categories).uniq
45
+ end
46
+
47
+ belongs_to :wallet, class_name: "Wallets::Wallet"
48
+ belongs_to :transfer, class_name: "Wallets::Transfer", optional: true
49
+
50
+ has_many :outgoing_allocations,
51
+ class_name: "Wallets::Allocation",
52
+ foreign_key: :transaction_id,
53
+ dependent: :destroy
54
+
55
+ has_many :incoming_allocations,
56
+ class_name: "Wallets::Allocation",
57
+ foreign_key: :source_transaction_id,
58
+ dependent: :destroy
59
+
60
+ validates :amount, presence: true, numericality: { only_integer: true }
61
+ validates :category, presence: true, inclusion: { in: ->(record) { record.class.categories } }
62
+ validate :remaining_amount_cannot_be_negative
63
+
64
+ before_save :sync_metadata_cache
65
+
66
+ scope :credits, -> { where("amount > 0") }
67
+ scope :debits, -> { where("amount < 0") }
68
+ scope :recent, -> { order(created_at: :desc) }
69
+ scope :by_category, ->(category) { where(category: category) }
70
+ scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
71
+ scope :expired, -> { where("expires_at < ?", Time.current) }
72
+
73
+ def metadata
74
+ @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
75
+ end
76
+
77
+ def metadata=(hash)
78
+ @indifferent_metadata = nil
79
+ super(hash.respond_to?(:to_h) ? hash.to_h : {})
80
+ end
81
+
82
+ def reload(*)
83
+ @indifferent_metadata = nil
84
+ super
85
+ end
86
+
87
+ def owner
88
+ wallet.owner
89
+ end
90
+
91
+ def expired?
92
+ expires_at.present? && expires_at < Time.current
93
+ end
94
+
95
+ def credit?
96
+ amount.positive?
97
+ end
98
+
99
+ def debit?
100
+ amount.negative?
101
+ end
102
+
103
+ def allocated_amount
104
+ incoming_allocations.sum(:amount)
105
+ end
106
+
107
+ def spent_amount
108
+ outgoing_allocations.sum(:amount)
109
+ end
110
+
111
+ def remaining_amount
112
+ return 0 unless credit?
113
+
114
+ amount - allocated_amount
115
+ end
116
+
117
+ def unbacked_amount
118
+ return 0 unless debit?
119
+
120
+ amount.abs - spent_amount
121
+ end
122
+
123
+ def balance_before
124
+ metadata[:balance_before]
125
+ end
126
+
127
+ def balance_after
128
+ metadata[:balance_after]
129
+ end
130
+
131
+ def sync_balance_snapshot!(before:, after:)
132
+ update!(metadata: metadata.merge(
133
+ balance_before: before,
134
+ balance_after: after
135
+ ))
136
+ end
137
+
138
+ private
139
+
140
+ def sync_metadata_cache
141
+ if @indifferent_metadata
142
+ write_attribute(:metadata, @indifferent_metadata.to_h)
143
+ elsif read_attribute(:metadata).nil?
144
+ write_attribute(:metadata, {})
145
+ end
146
+ end
147
+
148
+ def remaining_amount_cannot_be_negative
149
+ if credit? && remaining_amount.negative?
150
+ errors.add(:base, "Allocated amount exceeds transaction amount")
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ # A transfer records an internal movement of value between two wallets of the
5
+ # same asset. The actual balance impact lives in the linked transactions on
6
+ # each side so the ledger remains append-only.
7
+ #
8
+ # Transfers keep the outbound leg singular and the inbound legs plural so the
9
+ # receiver can preserve the sender's expiration buckets when one transfer
10
+ # consumes multiple source transactions with different expirations.
11
+ class Transfer < ApplicationRecord
12
+ class_attribute :embedded_table_name, default: nil
13
+ class_attribute :config_provider, default: -> { Wallets.configuration }
14
+ class_attribute :transaction_class_name, default: "Wallets::Transaction"
15
+
16
+ SUPPORTED_EXPIRATION_POLICIES = %w[preserve none fixed].freeze
17
+
18
+ def self.table_name
19
+ embedded_table_name || "#{resolved_config.table_prefix}transfers"
20
+ end
21
+
22
+ def self.resolved_config
23
+ value = config_provider
24
+ value.respond_to?(:call) ? value.call : value
25
+ end
26
+
27
+ def self.transaction_class
28
+ transaction_class_name.constantize
29
+ end
30
+
31
+ belongs_to :from_wallet, class_name: "Wallets::Wallet", inverse_of: :outgoing_transfers
32
+ belongs_to :to_wallet, class_name: "Wallets::Wallet", inverse_of: :incoming_transfers
33
+
34
+ has_many :transactions,
35
+ class_name: "Wallets::Transaction",
36
+ foreign_key: :transfer_id,
37
+ inverse_of: :transfer
38
+
39
+ validates :asset_code, presence: true
40
+ validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 }
41
+ validates :expiration_policy, presence: true, inclusion: { in: SUPPORTED_EXPIRATION_POLICIES }
42
+ validate :wallets_must_differ
43
+ validate :wallet_assets_match_transfer_asset
44
+
45
+ before_validation :normalize_asset_code!
46
+ before_validation :normalize_expiration_policy!
47
+ before_save :sync_metadata_cache
48
+
49
+ def metadata
50
+ @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
51
+ end
52
+
53
+ def metadata=(hash)
54
+ @indifferent_metadata = nil
55
+ super(hash.respond_to?(:to_h) ? hash.to_h : {})
56
+ end
57
+
58
+ def reload(*)
59
+ @indifferent_metadata = nil
60
+ super
61
+ end
62
+
63
+ def outbound_transactions
64
+ transfer_transactions_for(wallet_id: from_wallet_id).where("amount < 0")
65
+ end
66
+
67
+ def outbound_transaction
68
+ outbound_transactions.order(:id).first
69
+ end
70
+
71
+ def inbound_transactions
72
+ transfer_transactions_for(wallet_id: to_wallet_id).where("amount > 0")
73
+ end
74
+
75
+ def inbound_transaction
76
+ records = inbound_transactions.order(:id).limit(2).to_a
77
+ records.one? ? records.first : nil
78
+ end
79
+
80
+ private
81
+
82
+ def transaction_class
83
+ self.class.transaction_class
84
+ end
85
+
86
+ def normalize_asset_code!
87
+ self.asset_code = asset_code.to_s.strip.downcase.presence
88
+ end
89
+
90
+ def normalize_expiration_policy!
91
+ self.expiration_policy = expiration_policy.to_s.strip.downcase.presence
92
+ end
93
+
94
+ def wallets_must_differ
95
+ return if from_wallet.blank? || to_wallet.blank?
96
+ return if from_wallet_id != to_wallet_id
97
+
98
+ errors.add(:to_wallet, "must be different from from_wallet")
99
+ end
100
+
101
+ def wallet_assets_match_transfer_asset
102
+ return if from_wallet.blank? || to_wallet.blank? || asset_code.blank?
103
+ return if from_wallet.asset_code == asset_code && to_wallet.asset_code == asset_code
104
+
105
+ errors.add(:asset_code, "must match both wallets")
106
+ end
107
+
108
+ def sync_metadata_cache
109
+ if @indifferent_metadata
110
+ write_attribute(:metadata, @indifferent_metadata.to_h)
111
+ elsif read_attribute(:metadata).nil?
112
+ write_attribute(:metadata, {})
113
+ end
114
+ end
115
+
116
+ def transfer_transactions_for(wallet_id:)
117
+ return transaction_class.none unless persisted? && wallet_id.present?
118
+
119
+ transaction_class.where(transfer_id: id, wallet_id: wallet_id)
120
+ end
121
+ end
122
+ end