wallets 0.2.0 → 0.4.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.
@@ -1,24 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wallets
4
- # Transactions are the append-only source of truth for wallet balance changes.
4
+ # Transactions are the source of truth for wallet balance changes.
5
5
  # Positive rows add value, negative rows consume value, and transfers link both
6
6
  # sides of an internal movement through `transfer_id`.
7
7
  #
8
8
  # This class supports embedding: subclasses can override config and table
9
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
10
+ class TransactionBase < ApplicationRecord
11
+ # Abstract on purpose: embedders (like usage_credits) subclass THIS
12
+ # class, not the concrete Transaction below. ActiveRecord builds a
13
+ # subclass's attribute methods on its parent's, so a concrete parent
14
+ # forces a schema load of the wallets_* tables — which do not exist in
15
+ # apps that only run an embedded ledger (fresh usage_credits installs).
16
+ # An abstract parent makes each embedded subclass its own base_class
17
+ # and keeps the base tables out of the picture entirely.
18
+ self.abstract_class = true
19
+ include Wallets::Embeddable
20
+ include Wallets::HasMetadata
21
+
22
+ self.table_suffix = "transactions"
22
23
 
23
24
  DEFAULT_CATEGORIES = [
24
25
  "credit",
@@ -44,60 +45,54 @@ module Wallets
44
45
  (DEFAULT_CATEGORIES + extra_categories).uniq
45
46
  end
46
47
 
47
- belongs_to :wallet, class_name: "Wallets::Wallet"
48
+ # Explicit `optional:` flags because the gem's models load before Rails
49
+ # applies `belongs_to_required_by_default`.
50
+ belongs_to :wallet, class_name: "Wallets::Wallet", optional: false
48
51
  belongs_to :transfer, class_name: "Wallets::Transfer", optional: true
49
52
 
50
53
  has_many :outgoing_allocations,
51
- class_name: "Wallets::Allocation",
52
- foreign_key: :transaction_id,
53
- dependent: :destroy
54
+ class_name: "Wallets::Allocation",
55
+ foreign_key: :transaction_id,
56
+ dependent: :destroy
54
57
 
55
58
  has_many :incoming_allocations,
56
- class_name: "Wallets::Allocation",
57
- foreign_key: :source_transaction_id,
58
- dependent: :destroy
59
+ class_name: "Wallets::Allocation",
60
+ foreign_key: :source_transaction_id,
61
+ dependent: :destroy
59
62
 
60
- validates :amount, presence: true, numericality: { only_integer: true }
61
- validates :category, presence: true, inclusion: { in: ->(record) { record.class.categories } }
63
+ validates :amount, presence: true, numericality: {only_integer: true, other_than: 0}
64
+ validates :category, presence: true, inclusion: {in: ->(record) { record.class.categories }}
62
65
  validate :remaining_amount_cannot_be_negative
63
66
 
64
- before_save :sync_metadata_cache
65
-
66
- scope :credits, -> { where("amount > 0") }
67
- scope :debits, -> { where("amount < 0") }
67
+ # Qualify predicates through Arel so these scopes remain composable when a
68
+ # joined table also has `amount` / `expires_at` columns (transfers do).
69
+ # `arel_table` also respects embedded subclasses' custom table names.
70
+ scope :credits, -> { where(arel_table[:amount].gt(0)) }
71
+ scope :debits, -> { where(arel_table[:amount].lt(0)) }
68
72
  scope :recent, -> { order(created_at: :desc) }
69
73
  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
74
+ # `not_expired` and `expired` partition all transactions at any instant:
75
+ # a transaction expiring exactly "now" is already expired, matching the
76
+ # balance math, which only counts buckets that are strictly still alive.
77
+ # `at:` lets a mutation read the whole ledger at the one instant it
78
+ # sampled under the wallet lock.
79
+ scope :not_expired, ->(at: Time.current) { where(arel_table[:expires_at].eq(nil).or(arel_table[:expires_at].gt(at))) }
80
+ scope :expired, ->(at: Time.current) { where(arel_table[:expires_at].lteq(at)) }
86
81
 
87
82
  def owner
88
83
  wallet.owner
89
84
  end
90
85
 
91
- def expired?
92
- expires_at.present? && expires_at < Time.current
86
+ def expired?(at: Time.current)
87
+ expires_at.present? && expires_at <= at
93
88
  end
94
89
 
95
90
  def credit?
96
- amount.positive?
91
+ amount.to_i.positive?
97
92
  end
98
93
 
99
94
  def debit?
100
- amount.negative?
95
+ amount.to_i.negative?
101
96
  end
102
97
 
103
98
  def allocated_amount
@@ -120,6 +115,30 @@ module Wallets
120
115
  amount.abs - spent_amount
121
116
  end
122
117
 
118
+ # The part of this credit's remaining value an active hold keeps out of
119
+ # the available balance: all of it while a hold on this credit or on its
120
+ # whole wallet is active, otherwise none. Expired value is gone, not held,
121
+ # so expiry zeroes this while the hold rows stay readable as reasons.
122
+ #
123
+ # Wallet reservations are not apportioned among credits: they are an
124
+ # amount of the wallet, enforced when it spends, never a claim on a
125
+ # particular bucket.
126
+ def held_amount(at: Time.current)
127
+ covered?(at: at) ? remaining_amount : 0
128
+ end
129
+
130
+ # What this credit can still fund at `at`: nothing once expired or held.
131
+ # Debits fund nothing.
132
+ def available_amount(at: Time.current)
133
+ return 0 unless credit? && !expired?(at: at)
134
+
135
+ covered?(at: at) ? 0 : remaining_amount
136
+ end
137
+
138
+ def held?(at: Time.current)
139
+ held_amount(at: at).positive?
140
+ end
141
+
123
142
  def balance_before
124
143
  metadata[:balance_before]
125
144
  end
@@ -137,18 +156,47 @@ module Wallets
137
156
 
138
157
  private
139
158
 
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
159
  def remaining_amount_cannot_be_negative
149
160
  if credit? && remaining_amount.negative?
150
161
  errors.add(:base, "Allocated amount exceeds transaction amount")
151
162
  end
152
163
  end
164
+
165
+ # Asks the wallet class for its one definition of "held" (the same SQL
166
+ # behind `held_balance` and FEFO eligibility), so a credit can never read
167
+ # as held while the wallet spends it, or the other way round. Ledgers
168
+ # without holds answer without a single hold query.
169
+ def covered?(at:)
170
+ return false unless persisted? && credit? && !expired?(at: at)
171
+
172
+ wallet_class = association(:wallet).klass
173
+ return false unless wallet_class.holds_enabled?
174
+
175
+ self.class.where(id: id).where(wallet_class.coverage_sql(at: at)).exists?
176
+ end
177
+ end
178
+
179
+ # The concrete standalone ledger model (table: wallets_transactions via the
180
+ # default config). Embedders subclass TransactionBase instead.
181
+ #
182
+ # Hold associations live here, never on TransactionBase: a ledger keeps
183
+ # holds only when it opts in (`WalletBase.hold_class_name`), and a ledger
184
+ # that did not must never touch a holds table, not even on destroy.
185
+ class Transaction < TransactionBase
186
+ # The reasons this credit is held. Destroying the credit destroys them.
187
+ has_many :holds,
188
+ class_name: "Wallets::Hold",
189
+ foreign_key: :transaction_id,
190
+ inverse_of: :credit,
191
+ dependent: :destroy
192
+
193
+ # The reservation this debit captured, if any. Deliberately no
194
+ # `dependent:`: nullifying the link could leave a reservation reading
195
+ # "captured" with no debit to show for it, so the foreign key protects it
196
+ # instead. Wallet destruction removes holds before transactions.
197
+ has_one :captured_reservation,
198
+ class_name: "Wallets::Hold",
199
+ foreign_key: :capture_transaction_id,
200
+ inverse_of: :capture_transaction
153
201
  end
154
202
  end
@@ -3,65 +3,60 @@
3
3
  module Wallets
4
4
  # A transfer records an internal movement of value between two wallets of the
5
5
  # same asset. The actual balance impact lives in the linked transactions on
6
- # each side so the ledger remains append-only.
6
+ # each side so the transaction history remains explicit.
7
7
  #
8
8
  # Transfers keep the outbound leg singular and the inbound legs plural so the
9
9
  # receiver can preserve the sender's expiration buckets when one transfer
10
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 }
11
+ class TransferBase < ApplicationRecord
12
+ # Abstract on purpose: embedders (like usage_credits) subclass THIS
13
+ # class, not the concrete Transfer below. ActiveRecord builds a
14
+ # subclass's attribute methods on its parent's, so a concrete parent
15
+ # forces a schema load of the wallets_* tables — which do not exist in
16
+ # apps that only run an embedded ledger (fresh usage_credits installs).
17
+ # An abstract parent makes each embedded subclass its own base_class
18
+ # and keeps the base tables out of the picture entirely.
19
+ self.abstract_class = true
20
+ include Wallets::Embeddable
21
+ include Wallets::HasMetadata
22
+
23
+ self.table_suffix = "transfers"
24
+
14
25
  class_attribute :transaction_class_name, default: "Wallets::Transaction"
15
26
 
16
27
  SUPPORTED_EXPIRATION_POLICIES = %w[preserve none fixed].freeze
17
28
 
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
29
  def self.transaction_class
28
30
  transaction_class_name.constantize
29
31
  end
30
32
 
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
+ # Explicit `optional: false` because the gem's models load before Rails
34
+ # applies `belongs_to_required_by_default`.
35
+ belongs_to :from_wallet, class_name: "Wallets::Wallet", inverse_of: :outgoing_transfers, optional: false
36
+ belongs_to :to_wallet, class_name: "Wallets::Wallet", inverse_of: :incoming_transfers, optional: false
33
37
 
38
+ # When a transfer record goes away (e.g. one side's wallet or owner is
39
+ # destroyed), the counterparty's ledger rows must survive: only the link
40
+ # is cleared. The transaction metadata still carries `transfer_id` and the
41
+ # counterparty details for audit purposes.
34
42
  has_many :transactions,
35
- class_name: "Wallets::Transaction",
36
- foreign_key: :transfer_id,
37
- inverse_of: :transfer
43
+ class_name: "Wallets::Transaction",
44
+ foreign_key: :transfer_id,
45
+ inverse_of: :transfer,
46
+ dependent: :nullify
38
47
 
39
48
  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 }
49
+ validates :amount, presence: true, numericality: {only_integer: true, greater_than: 0}
50
+ validates :category, presence: true
51
+ validates :expiration_policy, presence: true, inclusion: {in: SUPPORTED_EXPIRATION_POLICIES}
42
52
  validate :wallets_must_differ
43
53
  validate :wallet_assets_match_transfer_asset
44
54
 
45
55
  before_validation :normalize_asset_code!
46
56
  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
57
 
63
58
  def outbound_transactions
64
- transfer_transactions_for(wallet_id: from_wallet_id).where("amount < 0")
59
+ transfer_transactions_for(wallet_id: from_wallet_id).debits
65
60
  end
66
61
 
67
62
  def outbound_transaction
@@ -69,7 +64,7 @@ module Wallets
69
64
  end
70
65
 
71
66
  def inbound_transactions
72
- transfer_transactions_for(wallet_id: to_wallet_id).where("amount > 0")
67
+ transfer_transactions_for(wallet_id: to_wallet_id).credits
73
68
  end
74
69
 
75
70
  def inbound_transaction
@@ -84,7 +79,7 @@ module Wallets
84
79
  end
85
80
 
86
81
  def normalize_asset_code!
87
- self.asset_code = asset_code.to_s.strip.downcase.presence
82
+ self.asset_code = Wallets.normalize_asset_code(asset_code).presence
88
83
  end
89
84
 
90
85
  def normalize_expiration_policy!
@@ -105,18 +100,15 @@ module Wallets
105
100
  errors.add(:asset_code, "must match both wallets")
106
101
  end
107
102
 
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
103
  def transfer_transactions_for(wallet_id:)
117
104
  return transaction_class.none unless persisted? && wallet_id.present?
118
105
 
119
106
  transaction_class.where(transfer_id: id, wallet_id: wallet_id)
120
107
  end
121
108
  end
109
+
110
+ # The concrete standalone ledger model (table: wallets_transfers via the
111
+ # default config). Embedders subclass TransferBase instead.
112
+ class Transfer < TransferBase
113
+ end
122
114
  end