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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +683 -27
- data/lib/generators/wallets/install_generator.rb +1 -1
- data/lib/generators/wallets/templates/add_wallets_holds.rb.erb +150 -0
- data/lib/generators/wallets/templates/create_wallets_tables.rb.erb +72 -3
- data/lib/generators/wallets/templates/initializer.rb +51 -18
- data/lib/generators/wallets/upgrade_generator.rb +76 -0
- data/lib/wallets/callback_context.rb +1 -0
- data/lib/wallets/callback_dispatcher.rb +51 -0
- data/lib/wallets/callbacks.rb +9 -40
- data/lib/wallets/configuration.rb +19 -11
- data/lib/wallets/engine.rb +5 -9
- data/lib/wallets/models/allocation.rb +48 -16
- data/lib/wallets/models/concerns/embeddable.rb +36 -0
- data/lib/wallets/models/concerns/has_metadata.rb +73 -0
- data/lib/wallets/models/concerns/has_wallets.rb +25 -18
- data/lib/wallets/models/hold.rb +196 -0
- data/lib/wallets/models/transaction.rb +102 -54
- data/lib/wallets/models/transfer.rb +38 -46
- data/lib/wallets/models/wallet.rb +1444 -298
- data/lib/wallets/railtie.rb +6 -3
- data/lib/wallets/version.rb +1 -1
- data/lib/wallets/whole_number.rb +37 -0
- data/lib/wallets.rb +14 -2
- data/sig/wallets.rbs +98 -0
- metadata +17 -17
- data/.gitignore +0 -36
- data/.rubocop.yml +0 -8
- data/.simplecov +0 -37
- data/AGENTS.md +0 -5
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -30
- data/context7.json +0 -4
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Wallets
|
|
4
|
-
# Transactions are the
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
class_name: "Wallets::Allocation",
|
|
55
|
+
foreign_key: :transaction_id,
|
|
56
|
+
dependent: :destroy
|
|
54
57
|
|
|
55
58
|
has_many :incoming_allocations,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
class_name: "Wallets::Allocation",
|
|
60
|
+
foreign_key: :source_transaction_id,
|
|
61
|
+
dependent: :destroy
|
|
59
62
|
|
|
60
|
-
validates :amount, presence: true, numericality: {
|
|
61
|
-
validates :category, presence: true, inclusion: {
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
scope :
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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: {
|
|
41
|
-
validates :
|
|
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).
|
|
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).
|
|
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.
|
|
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
|