wallets 0.1.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 +129 -0
- data/README.md +803 -62
- 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 +52 -10
- 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 +1445 -250
- 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 -16
- 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
|
@@ -5,45 +5,100 @@ module Wallets
|
|
|
5
5
|
#
|
|
6
6
|
# Balances are not maintained by incrementing a counter in place. Instead, the
|
|
7
7
|
# wallet derives its current balance from transactions and allocations so it
|
|
8
|
-
# can support
|
|
8
|
+
# can support expiration-aware consumption, transfers, and a durable audit
|
|
9
9
|
# trail at the same time.
|
|
10
10
|
#
|
|
11
|
+
# `balance` is the AVAILABLE balance: what can be spent right now. Holds keep
|
|
12
|
+
# value in the ledger but out of it, exactly the way expiry already does:
|
|
13
|
+
#
|
|
14
|
+
# balance (available) = P - C - R - N
|
|
15
|
+
# held_balance = C + R
|
|
16
|
+
# total_balance = P - N
|
|
17
|
+
#
|
|
18
|
+
# P is the remaining value of unexpired credits, C the part of P an active
|
|
19
|
+
# hold covers, R the active reservations, and N the debt no credit backs.
|
|
20
|
+
# Without holds, C and R are zero and nothing changes.
|
|
21
|
+
#
|
|
11
22
|
# This class supports embedding: subclasses can override config, callbacks,
|
|
12
23
|
# table names, and related model classes without affecting the base Wallets::*
|
|
13
24
|
# behavior in the same application.
|
|
14
|
-
class
|
|
25
|
+
class WalletBase < ApplicationRecord
|
|
26
|
+
# Abstract on purpose: embedders (like usage_credits) subclass THIS
|
|
27
|
+
# class, not the concrete Wallet below. ActiveRecord builds a
|
|
28
|
+
# subclass's attribute methods on its parent's, so a concrete parent
|
|
29
|
+
# forces a schema load of the wallets_* tables — which do not exist in
|
|
30
|
+
# apps that only run an embedded ledger (fresh usage_credits installs).
|
|
31
|
+
# An abstract parent makes each embedded subclass its own base_class
|
|
32
|
+
# and keeps the base tables out of the picture entirely.
|
|
33
|
+
self.abstract_class = true
|
|
34
|
+
include Wallets::Embeddable
|
|
35
|
+
include Wallets::HasMetadata
|
|
36
|
+
|
|
37
|
+
CORE_TRANSACTION_ATTRIBUTE_NAMES = %i[
|
|
38
|
+
id wallet wallet_id amount category expires_at metadata transfer
|
|
39
|
+
transfer_id balance_before balance_after created_at updated_at
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# Plain SELECTs on these adapters may answer from a stale snapshot inside
|
|
43
|
+
# a transaction (see `current_reads_need_locks?`).
|
|
44
|
+
SNAPSHOT_READ_ADAPTERS = /mysql|trilogy/i
|
|
45
|
+
private_constant :SNAPSHOT_READ_ADAPTERS
|
|
46
|
+
|
|
47
|
+
# The smallest step the wallet row's `updated_at` column can record (the
|
|
48
|
+
# timestamp marker of wallet tables without `ledger_revision`).
|
|
49
|
+
MARKER_STEP = Rational(1, 1_000_000)
|
|
50
|
+
private_constant :MARKER_STEP
|
|
51
|
+
|
|
52
|
+
# What `hold` holds when no credit is passed at all. A sentinel rather
|
|
53
|
+
# than nil, so a nil from a lookup that found nothing is an error instead
|
|
54
|
+
# of a freeze on the whole wallet.
|
|
55
|
+
WHOLE_WALLET = Object.new.freeze
|
|
56
|
+
private_constant :WHOLE_WALLET
|
|
57
|
+
|
|
58
|
+
# What `debit` and `transfer_to` spend when `from:` is left out: any
|
|
59
|
+
# eligible credit, soonest-expiring first. A sentinel for the same reason
|
|
60
|
+
# as WHOLE_WALLET: a nil from a lookup that found nothing must be an
|
|
61
|
+
# error, never a debit that quietly spends some other credit.
|
|
62
|
+
ELIGIBLE_CREDITS = Object.new.freeze
|
|
63
|
+
private_constant :ELIGIBLE_CREDITS
|
|
64
|
+
|
|
65
|
+
# The keywords `credit(..., hold: {...})` accepts: the ones `hold` takes.
|
|
66
|
+
ARRIVAL_HOLD_OPTIONS = %i[category until metadata].freeze
|
|
67
|
+
private_constant :ARRIVAL_HOLD_OPTIONS
|
|
68
|
+
|
|
69
|
+
self.table_suffix = "wallets"
|
|
70
|
+
|
|
15
71
|
# =========================================
|
|
16
72
|
# Embeddability Hooks
|
|
17
73
|
# =========================================
|
|
18
74
|
|
|
19
|
-
class_attribute :embedded_table_name, default: nil
|
|
20
|
-
class_attribute :config_provider, default: -> { Wallets.configuration }
|
|
21
75
|
class_attribute :callbacks_module, default: Wallets::Callbacks
|
|
22
76
|
class_attribute :transaction_class_name, default: "Wallets::Transaction"
|
|
23
77
|
class_attribute :allocation_class_name, default: "Wallets::Allocation"
|
|
24
78
|
class_attribute :transfer_class_name, default: "Wallets::Transfer"
|
|
79
|
+
# Holds are opt-in per ledger. nil keeps a ledger exactly as it was before
|
|
80
|
+
# holds existed: no hold SQL anywhere, destroy included. The concrete
|
|
81
|
+
# Wallet below opts in; an embedder opts in deliberately, with its own hold
|
|
82
|
+
# class, table and balance policy. Never inferred from a table's existence:
|
|
83
|
+
# an enabled ledger with a missing table must fail loudly, not quietly
|
|
84
|
+
# make held value spendable.
|
|
85
|
+
class_attribute :hold_class_name, default: nil
|
|
86
|
+
# Embedded ledgers may add their own transaction columns (for example,
|
|
87
|
+
# usage_credits adds `fulfillment_id`). Keeping an explicit allowlist
|
|
88
|
+
# prevents arbitrary keyword attributes from overriding accounting fields
|
|
89
|
+
# such as amount, wallet, category, metadata, or timestamps.
|
|
90
|
+
class_attribute :additional_transaction_attribute_names, default: [].freeze
|
|
25
91
|
class_attribute :callback_event_map, default: {
|
|
26
92
|
credited: :balance_credited,
|
|
27
93
|
debited: :balance_debited,
|
|
28
94
|
insufficient: :insufficient_balance,
|
|
29
95
|
low_balance: :low_balance_reached,
|
|
30
96
|
depleted: :balance_depleted,
|
|
31
|
-
transfer_completed: :transfer_completed
|
|
97
|
+
transfer_completed: :transfer_completed,
|
|
98
|
+
hold_created: :hold_created,
|
|
99
|
+
hold_released: :hold_released
|
|
32
100
|
}.freeze
|
|
33
101
|
|
|
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
102
|
def self.transaction_class
|
|
48
103
|
transaction_class_name.constantize
|
|
49
104
|
end
|
|
@@ -56,30 +111,102 @@ module Wallets
|
|
|
56
111
|
transfer_class_name.constantize
|
|
57
112
|
end
|
|
58
113
|
|
|
114
|
+
def self.hold_class
|
|
115
|
+
raise HoldsNotEnabled, "#{name} does not keep holds (its hold_class_name is nil)" unless holds_enabled?
|
|
116
|
+
|
|
117
|
+
hold_class_name.constantize
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.holds_enabled?
|
|
121
|
+
hold_class_name.present?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Whether this database can answer a plain read from a stale snapshot, so
|
|
125
|
+
# that a mutation may need locking reads to see the ledger as it is now.
|
|
126
|
+
# Under MySQL's (and MariaDB's) default repeatable read, a transaction's
|
|
127
|
+
# consistent snapshot is fixed by its FIRST plain read: a caller that read
|
|
128
|
+
# anything before calling a wallet command keeps that snapshot after the
|
|
129
|
+
# wallet lock is granted, and a plain SUM would miss what the previous
|
|
130
|
+
# lock holder committed. Locking reads always read the latest committed
|
|
131
|
+
# rows. PostgreSQL's default read committed takes a fresh snapshot per
|
|
132
|
+
# statement (and refuses FOR UPDATE beside an aggregate), and SQLite
|
|
133
|
+
# serializes writers, so both already read current rows. Whether a given
|
|
134
|
+
# mutation actually needs locking reads is `stale_snapshot?`.
|
|
135
|
+
#
|
|
136
|
+
# @api private Public only because wallet instances ask it through their
|
|
137
|
+
# class; not part of the supported API, and free to change in any release.
|
|
138
|
+
def self.current_reads_need_locks?
|
|
139
|
+
transaction_class.connection.adapter_name.match?(SNAPSHOT_READ_ADAPTERS)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# One definition of "held" for credits, shared by `held_balance`, FEFO
|
|
143
|
+
# eligibility and `Transaction#held?`, so the three can never disagree.
|
|
144
|
+
# SQL that is true for a credit row of this ledger that an active hold
|
|
145
|
+
# covers at `at`: a hold on that very credit, or a hold on its whole
|
|
146
|
+
# wallet. Correlated EXISTS, never a join, so a credit held for two
|
|
147
|
+
# reasons still counts once.
|
|
148
|
+
#
|
|
149
|
+
# @api private Public only so `Transaction#held?` and friends share this
|
|
150
|
+
# one definition; not part of the supported API, and free to change in
|
|
151
|
+
# any release.
|
|
152
|
+
def self.coverage_sql(at:, lock: false)
|
|
153
|
+
holds = hold_class.arel_table
|
|
154
|
+
credits = transaction_class.arel_table
|
|
155
|
+
active_holds = hold_class.active(at: at).select(Arel.sql("1"))
|
|
156
|
+
active_holds = active_holds.lock("FOR UPDATE") if lock && current_reads_need_locks?
|
|
157
|
+
|
|
158
|
+
credit_holds = active_holds.where(holds[:transaction_id].eq(credits[:id]))
|
|
159
|
+
wallet_holds = active_holds.coverage.wallet_scoped.where(holds[:wallet_id].eq(credits[:wallet_id]))
|
|
160
|
+
|
|
161
|
+
"(EXISTS (#{credit_holds.to_sql}) OR EXISTS (#{wallet_holds.to_sql}))"
|
|
162
|
+
end
|
|
163
|
+
|
|
59
164
|
# =========================================
|
|
60
165
|
# Associations & Validations
|
|
61
166
|
# =========================================
|
|
62
167
|
|
|
63
|
-
|
|
168
|
+
# `optional: false` is explicit everywhere because the gem's models load
|
|
169
|
+
# before Rails applies `belongs_to_required_by_default`, so the host app's
|
|
170
|
+
# default never reaches these associations.
|
|
171
|
+
belongs_to :owner, polymorphic: true, optional: false
|
|
64
172
|
|
|
65
|
-
|
|
173
|
+
# foreign_key is explicit because this association is declared on the
|
|
174
|
+
# abstract WalletBase: ActiveRecord would otherwise derive it from the
|
|
175
|
+
# declaring class name ("wallet_base_id").
|
|
176
|
+
has_many :transactions, class_name: "Wallets::Transaction", foreign_key: :wallet_id, inverse_of: :wallet, dependent: :destroy
|
|
66
177
|
has_many :outgoing_transfers,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
178
|
+
class_name: "Wallets::Transfer",
|
|
179
|
+
foreign_key: :from_wallet_id,
|
|
180
|
+
dependent: :destroy,
|
|
181
|
+
inverse_of: :from_wallet
|
|
71
182
|
has_many :incoming_transfers,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
validates :asset_code, presence: true, uniqueness: {
|
|
78
|
-
|
|
79
|
-
|
|
183
|
+
class_name: "Wallets::Transfer",
|
|
184
|
+
foreign_key: :to_wallet_id,
|
|
185
|
+
dependent: :destroy,
|
|
186
|
+
inverse_of: :to_wallet
|
|
187
|
+
|
|
188
|
+
validates :asset_code, presence: true, uniqueness: {scope: [:owner_type, :owner_id]}
|
|
189
|
+
# Signed on purpose: available balance can drop below zero without any
|
|
190
|
+
# overdraft (reserve 80 of 100, then a fraud hold covers the 100; or
|
|
191
|
+
# reserved value expires). A save-time floor would reject the very hold,
|
|
192
|
+
# release or incoming credit that follows. Commands enforce funding under
|
|
193
|
+
# the wallet lock instead. The cached column is what is validated, never
|
|
194
|
+
# the `balance` reader (see `read_attribute_for_validation`).
|
|
195
|
+
validates :balance, numericality: {only_integer: true}
|
|
80
196
|
|
|
81
197
|
before_validation :normalize_asset_code!
|
|
82
|
-
|
|
198
|
+
|
|
199
|
+
# A caller's own write to the wallet row inside a transaction leaves that
|
|
200
|
+
# transaction reading its own version of the row, which then matches the
|
|
201
|
+
# locked row even while its ledger snapshot is stale (see
|
|
202
|
+
# `stale_snapshot?`). Such a wallet counts as stale for the rest of the
|
|
203
|
+
# transaction: conservative, and it only ever costs locking reads.
|
|
204
|
+
# Creating the wallet is exempt (nobody else can have touched a ledger
|
|
205
|
+
# nobody else can see), and so is the ledger's own refresh. Writes that
|
|
206
|
+
# skip Active Record callbacks (raw SQL, `update_columns`) are outside the
|
|
207
|
+
# supported API.
|
|
208
|
+
after_save :remember_own_write, if: :saved_changes?, unless: :previously_new_record?
|
|
209
|
+
after_touch :remember_own_write
|
|
83
210
|
|
|
84
211
|
# =========================================
|
|
85
212
|
# Class Methods
|
|
@@ -89,33 +216,35 @@ module Wallets
|
|
|
89
216
|
def create_for_owner!(owner:, asset_code:, initial_balance: 0, metadata: {})
|
|
90
217
|
initial_balance = normalize_initial_balance(initial_balance)
|
|
91
218
|
asset_code = normalize_asset_code(asset_code)
|
|
92
|
-
metadata = metadata.respond_to?(:to_h) ? metadata.to_h : {}
|
|
93
219
|
|
|
94
220
|
existing_wallet = find_by(owner: owner, asset_code: asset_code)
|
|
95
221
|
return existing_wallet if existing_wallet.present?
|
|
96
222
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
223
|
+
# `requires_new` gives us a savepoint when we are already inside a
|
|
224
|
+
# caller's transaction (e.g. `after_create` wallet auto-creation), and
|
|
225
|
+
# the rescue lives OUTSIDE the transaction block. Both matter on
|
|
226
|
+
# PostgreSQL: a concurrent duplicate INSERT aborts the transaction it
|
|
227
|
+
# ran in, so recovering with a SELECT only works after that
|
|
228
|
+
# transaction (or savepoint) has rolled back.
|
|
229
|
+
begin
|
|
230
|
+
transaction(requires_new: true) do
|
|
231
|
+
wallet = create!(
|
|
232
|
+
owner: owner,
|
|
233
|
+
asset_code: asset_code,
|
|
234
|
+
balance: 0,
|
|
235
|
+
metadata: metadata
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
wallet.credit(initial_balance, **initial_balance_credit_attributes) if initial_balance.positive?
|
|
104
239
|
|
|
105
|
-
|
|
106
|
-
wallet.credit(initial_balance, **initial_balance_credit_attributes)
|
|
240
|
+
wallet
|
|
107
241
|
end
|
|
108
|
-
|
|
109
|
-
wallet
|
|
110
242
|
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
|
|
111
|
-
|
|
112
|
-
raise error if wallet.nil?
|
|
243
|
+
raise error unless record_conflict_due_to_existing_wallet?(error)
|
|
113
244
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
raise error
|
|
118
|
-
end
|
|
245
|
+
# Two callers raced to create the same wallet; the loser gracefully
|
|
246
|
+
# returns the winner's row.
|
|
247
|
+
find_by(owner: owner, asset_code: asset_code) || raise(error)
|
|
119
248
|
end
|
|
120
249
|
end
|
|
121
250
|
|
|
@@ -124,64 +253,94 @@ module Wallets
|
|
|
124
253
|
def initial_balance_credit_attributes
|
|
125
254
|
{
|
|
126
255
|
category: :adjustment,
|
|
127
|
-
metadata: {
|
|
256
|
+
metadata: {reason: "initial_balance"}
|
|
128
257
|
}
|
|
129
258
|
end
|
|
130
259
|
|
|
131
260
|
def normalize_initial_balance(value)
|
|
132
261
|
return 0 if value.nil?
|
|
133
|
-
raise ArgumentError, "Initial balance must be a whole number" unless value == value.to_i
|
|
134
262
|
|
|
135
|
-
value = value
|
|
263
|
+
value = Wallets::WholeNumber.parse(value, name: "Initial balance")
|
|
136
264
|
raise ArgumentError, "Initial balance cannot be negative" if value.negative?
|
|
137
265
|
|
|
138
266
|
value
|
|
139
267
|
end
|
|
140
268
|
|
|
141
269
|
def normalize_asset_code(value)
|
|
142
|
-
value.
|
|
270
|
+
Wallets.normalize_asset_code(value).presence || raise(ArgumentError, "Asset code is required")
|
|
143
271
|
end
|
|
144
272
|
|
|
145
273
|
def record_conflict_due_to_existing_wallet?(error)
|
|
146
274
|
return true if error.is_a?(ActiveRecord::RecordNotUnique)
|
|
147
|
-
return false unless error.is_a?(ActiveRecord::RecordInvalid)
|
|
148
275
|
|
|
149
276
|
error.record.errors.of_kind?(:asset_code, :taken)
|
|
150
277
|
end
|
|
151
278
|
end
|
|
152
279
|
|
|
153
280
|
# =========================================
|
|
154
|
-
#
|
|
281
|
+
# Balance & History
|
|
155
282
|
# =========================================
|
|
156
283
|
|
|
157
|
-
|
|
158
|
-
|
|
284
|
+
# The available balance: what can be spent right now. Held, reserved and
|
|
285
|
+
# expired value is excluded.
|
|
286
|
+
def balance
|
|
287
|
+
current_balance
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# A ledger without holds computes exactly what 0.3 did, through the two
|
|
291
|
+
# helpers an embedder may override (D12). With holds, every term comes
|
|
292
|
+
# from one statement (see `balance_components`).
|
|
293
|
+
def current_balance
|
|
294
|
+
return positive_remaining_balance - unbacked_negative_balance unless holds_enabled?
|
|
295
|
+
|
|
296
|
+
balance_components.available
|
|
159
297
|
end
|
|
160
298
|
|
|
161
|
-
def
|
|
162
|
-
|
|
163
|
-
super(hash.respond_to?(:to_h) ? hash.to_h : {})
|
|
299
|
+
def available_balance
|
|
300
|
+
balance
|
|
164
301
|
end
|
|
165
302
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
303
|
+
# Validations read the cached `balance` column. The `balance` reader
|
|
304
|
+
# computes the available balance from the whole ledger, so validating it
|
|
305
|
+
# would run that statement on every save (several times per command) and
|
|
306
|
+
# make even `create!` read the holds table.
|
|
307
|
+
def read_attribute_for_validation(attribute)
|
|
308
|
+
(attribute.to_s == "balance") ? read_attribute(:balance) : super
|
|
169
309
|
end
|
|
170
310
|
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
311
|
+
# Value in the ledger that holds keep out of `balance`: held credits plus
|
|
312
|
+
# active reservations. It can exceed `total_balance` (a hold over value
|
|
313
|
+
# that is already reserved, or reservations whose backing expired), and is
|
|
314
|
+
# deliberately not capped.
|
|
315
|
+
def held_balance
|
|
316
|
+
return 0 unless holds_enabled?
|
|
174
317
|
|
|
175
|
-
|
|
176
|
-
current_balance
|
|
318
|
+
balance_components.held
|
|
177
319
|
end
|
|
178
320
|
|
|
179
|
-
|
|
180
|
-
|
|
321
|
+
# Everything the ledger holds for the owner, spendable or not: P - N.
|
|
322
|
+
# Without holds it is simply `balance`, whatever policy an embedder's
|
|
323
|
+
# `current_balance` applies.
|
|
324
|
+
def total_balance
|
|
325
|
+
return balance unless holds_enabled?
|
|
326
|
+
|
|
327
|
+
balance_components.total
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Whether a hold covers the whole wallet, including credits received
|
|
331
|
+
# later. A pending reservation is not a blanket hold.
|
|
332
|
+
def on_hold?
|
|
333
|
+
return false unless holds_enabled?
|
|
334
|
+
|
|
335
|
+
wallet_holds.coverage.wallet_scoped.active(at: evaluation_time).exists?
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def holds_enabled?
|
|
339
|
+
self.class.holds_enabled?
|
|
181
340
|
end
|
|
182
341
|
|
|
183
342
|
def history
|
|
184
|
-
transactions.order(created_at: :asc)
|
|
343
|
+
transactions.order(created_at: :asc, id: :asc)
|
|
185
344
|
end
|
|
186
345
|
|
|
187
346
|
def has_enough_balance?(amount)
|
|
@@ -195,117 +354,294 @@ module Wallets
|
|
|
195
354
|
# Credit & Debit Operations
|
|
196
355
|
# =========================================
|
|
197
356
|
|
|
198
|
-
|
|
357
|
+
# Adds value to the wallet. With `hold:` or `available_at:` the value
|
|
358
|
+
# arrives held: the credit and its hold are written in one step, so it is
|
|
359
|
+
# never spendable in between, and the callbacks report the finished
|
|
360
|
+
# operation.
|
|
361
|
+
#
|
|
362
|
+
# wallet.credit(751, category: :reward, hold: :pending_validation)
|
|
363
|
+
# wallet.credit(751, category: :reward, hold: {category: :pending_validation, until: 3.months.from_now, metadata: {ride_id: 7}})
|
|
364
|
+
# wallet.credit(500, category: :reward, available_at: 14.days.from_now) # held as :pending until then
|
|
365
|
+
def credit(amount, metadata: {}, category: :credit, expires_at: nil, transfer: nil, hold: nil, available_at: nil, **extra_transaction_attributes)
|
|
199
366
|
metadata = normalize_metadata(metadata)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
367
|
+
extra_transaction_attributes = validate_additional_transaction_attributes!(extra_transaction_attributes)
|
|
368
|
+
arrival_hold = arrival_hold_attributes(hold, available_at)
|
|
369
|
+
|
|
370
|
+
# A savepoint even inside a caller's transaction, like every command: a
|
|
371
|
+
# credit that cannot get its hold must not survive as a spendable credit
|
|
372
|
+
# because the caller rescued the error and committed.
|
|
373
|
+
with_lock(requires_new: true) do
|
|
374
|
+
mutating_at(Time.current) do
|
|
375
|
+
# Value that becomes available at an instant already reached is
|
|
376
|
+
# simply available: there is nothing left to hold.
|
|
377
|
+
arrival_hold = nil if available_at && arrival_hold[:releases_at] <= evaluation_time
|
|
378
|
+
|
|
379
|
+
apply_credit(
|
|
380
|
+
amount,
|
|
381
|
+
metadata: metadata,
|
|
382
|
+
category: category,
|
|
383
|
+
expires_at: expires_at,
|
|
384
|
+
transfer: transfer,
|
|
385
|
+
extra_attributes: extra_transaction_attributes,
|
|
386
|
+
hold: arrival_hold
|
|
387
|
+
)
|
|
388
|
+
end
|
|
210
389
|
end
|
|
211
390
|
end
|
|
212
391
|
|
|
213
|
-
|
|
392
|
+
# Takes value out of the wallet. Ordinarily it spends eligible credits,
|
|
393
|
+
# soonest-expiring first, and never touches held value.
|
|
394
|
+
#
|
|
395
|
+
# `from:` names the credits to take it from instead, in the order given.
|
|
396
|
+
# It is the one way to take value a hold covers, so it is how a hold ends
|
|
397
|
+
# the other way: releasing gives the value back, `from:` takes it away.
|
|
398
|
+
# The debit is allocated to the named credits in full or not at all,
|
|
399
|
+
# never to other credits and never to debt, and the holds stay exactly as
|
|
400
|
+
# they were: a cancelled credit keeps its reasons with nothing left to
|
|
401
|
+
# hold.
|
|
402
|
+
#
|
|
403
|
+
# wallet.debit(751, category: :reversal, from: credit, metadata: {reason: "reward_rejected"})
|
|
404
|
+
# wallet.debit(150, category: :reversal, from: [first_credit, second_credit])
|
|
405
|
+
#
|
|
406
|
+
# Held value needs no funding, but the rest of a targeted debit does:
|
|
407
|
+
# with overdrafts off, what it takes from unheld credits must fit in the
|
|
408
|
+
# available balance, so naming an unheld credit cannot spend value that a
|
|
409
|
+
# reservation set aside, nor deepen a deficit.
|
|
410
|
+
def debit(amount, metadata: {}, category: :debit, transfer: nil, from: ELIGIBLE_CREDITS, **extra_transaction_attributes)
|
|
214
411
|
metadata = normalize_metadata(metadata)
|
|
412
|
+
extra_transaction_attributes = validate_additional_transaction_attributes!(extra_transaction_attributes)
|
|
413
|
+
sources = named_credits(from)
|
|
215
414
|
|
|
216
|
-
with_lock do
|
|
415
|
+
with_lock(requires_new: true) do
|
|
217
416
|
apply_debit(
|
|
218
417
|
amount,
|
|
219
418
|
metadata: metadata,
|
|
220
419
|
category: category,
|
|
221
420
|
transfer: transfer,
|
|
222
|
-
extra_attributes: extra_transaction_attributes
|
|
421
|
+
extra_attributes: extra_transaction_attributes,
|
|
422
|
+
from: sources
|
|
223
423
|
)
|
|
224
424
|
end
|
|
225
425
|
end
|
|
226
426
|
|
|
427
|
+
# =========================================
|
|
428
|
+
# Holds & Reservations
|
|
429
|
+
# =========================================
|
|
430
|
+
|
|
431
|
+
# Keeps value in the wallet but out of `balance` until the hold is
|
|
432
|
+
# released or its deadline passes. Given a credit, it holds that credit's
|
|
433
|
+
# whole remaining value; without one, it holds every credit in the wallet,
|
|
434
|
+
# including credits that arrive later. Holding an already held credit is
|
|
435
|
+
# fine: each hold is an independent reason, released on its own.
|
|
436
|
+
#
|
|
437
|
+
# wallet.hold(credit, category: :fraud_review, metadata: {case_id: 42})
|
|
438
|
+
# wallet.hold(category: :chargeback, until: 30.days.from_now)
|
|
439
|
+
#
|
|
440
|
+
# Only leaving the credit out holds the whole wallet: an explicit nil
|
|
441
|
+
# (a credit that was looked up and not found) is refused.
|
|
442
|
+
def hold(credit = WHOLE_WALLET, category:, until: nil, metadata: {})
|
|
443
|
+
ensure_holds_enabled!
|
|
444
|
+
raise ArgumentError, "Pass no credit to hold the whole wallet; nil is not a credit" if credit.nil?
|
|
445
|
+
|
|
446
|
+
credit = nil if credit.equal?(WHOLE_WALLET)
|
|
447
|
+
metadata = normalize_metadata(metadata)
|
|
448
|
+
# `until` is a Ruby keyword: the argument reads as plain English at the
|
|
449
|
+
# call site, and the binding is the only way to reach it in here.
|
|
450
|
+
releases_at = normalize_deadline(binding.local_variable_get(:until))
|
|
451
|
+
|
|
452
|
+
with_lock(requires_new: true) do
|
|
453
|
+
mutating_at(Time.current) do
|
|
454
|
+
# The target first: a wrong argument is a wrong argument, whatever
|
|
455
|
+
# the deadline says.
|
|
456
|
+
ensure_holdable!(credit) unless credit.nil?
|
|
457
|
+
ensure_deadline_ahead!(releases_at)
|
|
458
|
+
|
|
459
|
+
place_hold!(credit: credit, category: category, releases_at: releases_at, metadata: metadata)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Sets an amount of the available balance aside, like a pending card
|
|
465
|
+
# charge; reservations add up. Funded exactly like a debit: the amount
|
|
466
|
+
# must fit in `balance` unless `allow_negative_balance` is on.
|
|
467
|
+
#
|
|
468
|
+
# wallet.reserve(3_000, category: :booking, until: 7.days.from_now, metadata: {booking_id: 9})
|
|
469
|
+
def reserve(amount, category:, until: nil, metadata: {})
|
|
470
|
+
ensure_holds_enabled!
|
|
471
|
+
amount = normalize_positive_amount!(amount)
|
|
472
|
+
metadata = normalize_metadata(metadata)
|
|
473
|
+
releases_at = normalize_deadline(binding.local_variable_get(:until))
|
|
474
|
+
|
|
475
|
+
with_lock(requires_new: true) do
|
|
476
|
+
mutating_at(Time.current) do
|
|
477
|
+
ensure_deadline_ahead!(releases_at)
|
|
478
|
+
available = balance
|
|
479
|
+
|
|
480
|
+
if amount > available && !allow_negative_balance?
|
|
481
|
+
reject_insufficient_balance!(amount, available, metadata)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
place_hold!(amount: amount, category: category, releases_at: releases_at, metadata: metadata, previous_balance: available)
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
227
489
|
# =========================================
|
|
228
490
|
# Transfers
|
|
229
491
|
# =========================================
|
|
230
492
|
|
|
231
|
-
|
|
493
|
+
# Moves value to another wallet of the same asset. `from:` works as it
|
|
494
|
+
# does on `debit`: the outbound leg is taken from the named credits only,
|
|
495
|
+
# held value included, and the inbound legs are credited as usual.
|
|
496
|
+
#
|
|
497
|
+
# wallet.transfer_to(platform.wallet, 751, category: :reversal, from: credit)
|
|
498
|
+
def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expiration_policy: nil, expires_at: nil, from: ELIGIBLE_CREDITS)
|
|
232
499
|
raise InvalidTransfer, "Target wallet is required" if other_wallet.nil?
|
|
500
|
+
raise InvalidTransfer, "Source wallet must be persisted" unless persisted?
|
|
233
501
|
raise InvalidTransfer, "Target wallet must be persisted" unless other_wallet.persisted?
|
|
502
|
+
# The class first: two ledgers keep separate tables, so a wallet of
|
|
503
|
+
# another class can share this wallet's id without being this wallet.
|
|
504
|
+
raise InvalidTransfer, "Wallet classes must match" unless other_wallet.instance_of?(self.class)
|
|
234
505
|
raise InvalidTransfer, "Cannot transfer to the same wallet" if other_wallet.id == id
|
|
235
506
|
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
507
|
|
|
238
508
|
amount = normalize_positive_amount!(amount)
|
|
239
509
|
metadata = normalize_metadata(metadata)
|
|
240
510
|
resolved_policy, inbound_expires_at = resolve_transfer_expiration!(expiration_policy, expires_at)
|
|
511
|
+
sources = named_credits(from)
|
|
241
512
|
|
|
242
|
-
|
|
513
|
+
# A savepoint even inside a caller's transaction, like every command: a
|
|
514
|
+
# transfer whose inbound leg fails must take its outbound debit and its
|
|
515
|
+
# Transfer row with it, even when the caller rescues and commits.
|
|
516
|
+
self.class.transaction(requires_new: true) do
|
|
243
517
|
lock_wallet_pair!(other_wallet)
|
|
518
|
+
now = Time.current
|
|
519
|
+
|
|
520
|
+
# The recipient pins the same instant in its own `apply_credit`.
|
|
521
|
+
mutating_at(now) do
|
|
522
|
+
previous_balance = balance
|
|
523
|
+
|
|
524
|
+
# Insufficient-balance gate.
|
|
525
|
+
#
|
|
526
|
+
# `allow_negative_balance` is the single source of truth for whether
|
|
527
|
+
# the gem permits a wallet to drop below zero. Direct `wallet.debit`
|
|
528
|
+
# has always honored it via `apply_debit`; transfers must too, or
|
|
529
|
+
# the flag is half-applied and surprising (debits go through, the
|
|
530
|
+
# canonical transfer primitive doesn't).
|
|
531
|
+
#
|
|
532
|
+
# When the flag is OFF, this is the only chance to refuse cleanly:
|
|
533
|
+
# we want to dispatch `:insufficient_balance` for the observability
|
|
534
|
+
# callback, then raise BEFORE creating the Transfer row. (The later
|
|
535
|
+
# `apply_debit` call would also catch this, but only after a
|
|
536
|
+
# pointless Transfer row was created.)
|
|
537
|
+
#
|
|
538
|
+
# When the flag is ON, we let the transfer through. `apply_debit`
|
|
539
|
+
# below will allocate as many eligible buckets as exist; the
|
|
540
|
+
# remaining shortfall lands as an unbacked negative transaction the
|
|
541
|
+
# wallet's `balance` accounting accommodates via
|
|
542
|
+
# `unbacked_negative_balance`.
|
|
543
|
+
#
|
|
544
|
+
# A targeted transfer is funded like a targeted debit instead (see
|
|
545
|
+
# `named_buckets!`), and checked here for the same reason: a refusal
|
|
546
|
+
# must leave no Transfer row behind.
|
|
547
|
+
if sources
|
|
548
|
+
named_buckets!(sources, amount, previous_balance, metadata)
|
|
549
|
+
elsif amount > previous_balance && !allow_negative_balance?
|
|
550
|
+
reject_insufficient_balance!(amount, previous_balance, metadata)
|
|
551
|
+
end
|
|
244
552
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
553
|
+
# `:preserve` gives each inbound credit the expiration of the source
|
|
554
|
+
# bucket it was allocated from. A portion of the outbound debit that
|
|
555
|
+
# no bucket backs has no source expiration to preserve, and
|
|
556
|
+
# `build_preserved_transfer_inbound_credit_specs` would refuse with
|
|
557
|
+
# `InvalidTransfer` on the count mismatch, defeating the very callers
|
|
558
|
+
# who opted into negative balances. The only honest fallback is to
|
|
559
|
+
# collapse the inbound side to a single evergreen credit (`:none`):
|
|
560
|
+
# value created without a source bucket has no source expiration.
|
|
561
|
+
#
|
|
562
|
+
# The fallback is decided by what allocation will actually back,
|
|
563
|
+
# never by the available balance. Available also nets out
|
|
564
|
+
# reservations and earlier debt, so a transfer can exceed it while
|
|
565
|
+
# FEFO still backs every unit from eligible buckets (a reservation
|
|
566
|
+
# outstanding, or old debt followed by fresh credits); such a
|
|
567
|
+
# transfer keeps its source expirations. `eligible_remaining_balance`
|
|
568
|
+
# sums exactly the buckets `allocate_debit!` walks, at this instant
|
|
569
|
+
# and under these locks, so it is the outcome, not an estimate.
|
|
570
|
+
#
|
|
571
|
+
# A targeted transfer never has an unbacked portion (the named
|
|
572
|
+
# credits fund all of it or the transfer is refused above), so it
|
|
573
|
+
# always keeps its sources' expirations, even when they are held
|
|
574
|
+
# and nothing is available.
|
|
575
|
+
#
|
|
576
|
+
# Callers who care about a specific expiration on the inbound side
|
|
577
|
+
# should pass `expiration_policy: :fixed, expires_at: …`. Those are
|
|
578
|
+
# honored unchanged because they don't depend on source-bucket
|
|
579
|
+
# walking. Same for an explicit `:none`.
|
|
580
|
+
if resolved_policy == "preserve" && sources.nil? && amount > eligible_remaining_balance
|
|
581
|
+
resolved_policy = "none"
|
|
582
|
+
inbound_expires_at = nil
|
|
583
|
+
end
|
|
260
584
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
585
|
+
transfer = transfer_class.create!(
|
|
586
|
+
from_wallet: self,
|
|
587
|
+
to_wallet: other_wallet,
|
|
588
|
+
asset_code: asset_code,
|
|
589
|
+
amount: amount,
|
|
590
|
+
category: category,
|
|
591
|
+
expiration_policy: resolved_policy,
|
|
592
|
+
metadata: metadata
|
|
593
|
+
)
|
|
267
594
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
"
|
|
273
|
-
|
|
274
|
-
"counterparty_owner_type" => other_wallet.owner_type
|
|
275
|
-
),
|
|
276
|
-
transfer: transfer
|
|
277
|
-
)
|
|
595
|
+
shared_metadata = metadata.to_h.deep_stringify_keys.merge(
|
|
596
|
+
"transfer_id" => transfer.id,
|
|
597
|
+
"asset_code" => asset_code,
|
|
598
|
+
"transfer_category" => category.to_s,
|
|
599
|
+
"transfer_expiration_policy" => resolved_policy
|
|
600
|
+
)
|
|
278
601
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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,
|
|
602
|
+
_outbound_transaction, allocations = apply_debit_with_allocations(
|
|
603
|
+
amount,
|
|
604
|
+
category: :transfer_out,
|
|
290
605
|
metadata: shared_metadata.merge(
|
|
291
|
-
"counterparty_wallet_id" => id,
|
|
292
|
-
"counterparty_owner_id" => owner_id,
|
|
293
|
-
"counterparty_owner_type" => owner_type
|
|
606
|
+
"counterparty_wallet_id" => other_wallet.id,
|
|
607
|
+
"counterparty_owner_id" => other_wallet.owner_id,
|
|
608
|
+
"counterparty_owner_type" => other_wallet.owner_type
|
|
294
609
|
),
|
|
295
|
-
|
|
296
|
-
|
|
610
|
+
transfer: transfer,
|
|
611
|
+
from: sources
|
|
297
612
|
)
|
|
298
|
-
end
|
|
299
613
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
614
|
+
build_transfer_inbound_credit_specs(
|
|
615
|
+
transfer: transfer,
|
|
616
|
+
allocations: allocations,
|
|
617
|
+
amount: amount,
|
|
618
|
+
expiration_policy: resolved_policy,
|
|
619
|
+
expires_at: inbound_expires_at
|
|
620
|
+
).each do |spec|
|
|
621
|
+
other_wallet.send(
|
|
622
|
+
:apply_credit,
|
|
623
|
+
spec[:amount],
|
|
624
|
+
category: :transfer_in,
|
|
625
|
+
metadata: shared_metadata.merge(
|
|
626
|
+
"counterparty_wallet_id" => id,
|
|
627
|
+
"counterparty_owner_id" => owner_id,
|
|
628
|
+
"counterparty_owner_type" => owner_type
|
|
629
|
+
),
|
|
630
|
+
expires_at: spec[:expires_at],
|
|
631
|
+
transfer: transfer,
|
|
632
|
+
at: now
|
|
633
|
+
)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
dispatch_callback(:transfer_completed,
|
|
637
|
+
wallet: self,
|
|
638
|
+
transfer: transfer,
|
|
639
|
+
amount: amount,
|
|
640
|
+
category: category,
|
|
641
|
+
metadata: metadata)
|
|
307
642
|
|
|
308
|
-
|
|
643
|
+
transfer
|
|
644
|
+
end
|
|
309
645
|
end
|
|
310
646
|
end
|
|
311
647
|
|
|
@@ -335,6 +671,10 @@ module Wallets
|
|
|
335
671
|
self.class.transfer_class
|
|
336
672
|
end
|
|
337
673
|
|
|
674
|
+
def hold_class
|
|
675
|
+
self.class.hold_class
|
|
676
|
+
end
|
|
677
|
+
|
|
338
678
|
# =========================================
|
|
339
679
|
# Callback Dispatching
|
|
340
680
|
# =========================================
|
|
@@ -343,54 +683,294 @@ module Wallets
|
|
|
343
683
|
event = self.class.callback_event_map[kind]
|
|
344
684
|
return if event.nil?
|
|
345
685
|
|
|
346
|
-
callbacks
|
|
686
|
+
callback_dispatcher = callbacks
|
|
687
|
+
dispatch = -> { callback_dispatcher.dispatch(event, **data) }
|
|
688
|
+
|
|
689
|
+
# Successful ledger callbacks describe committed facts, so they wait for
|
|
690
|
+
# the commit. They are queued on the innermost open transaction, the
|
|
691
|
+
# command's own savepoint: a failure later in the command rolls it back
|
|
692
|
+
# and drops them, even when the caller rescues and commits; a released
|
|
693
|
+
# savepoint hands them to its parent; an enclosing rollback drops them.
|
|
694
|
+
# The same rules as Active Record's own after_commit, which also treats a
|
|
695
|
+
# `transaction(joinable: false)` as a commit boundary. Failure callbacks
|
|
696
|
+
# are intentionally immediate because their transaction is about to roll
|
|
697
|
+
# back by design.
|
|
698
|
+
if kind == :insufficient || !ActiveRecord.respond_to?(:after_all_transactions_commit)
|
|
699
|
+
dispatch.call
|
|
700
|
+
else
|
|
701
|
+
ActiveRecord.after_all_transactions_commit(&dispatch)
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# =========================================
|
|
706
|
+
# Evaluation Instant
|
|
707
|
+
# =========================================
|
|
708
|
+
|
|
709
|
+
# A mutation decides on several accounting reads: affordability, FEFO
|
|
710
|
+
# eligibility, the cached balance, snapshots and callbacks. They must all
|
|
711
|
+
# describe one instant (a credit expiring or a hold lapsing between two of
|
|
712
|
+
# them would make the operation contradict itself) and the rows as they
|
|
713
|
+
# are now, not as an older snapshot saw them. `evaluating_at` pins both for
|
|
714
|
+
# the block, and every balance read inside it picks them up, including an
|
|
715
|
+
# embedder's own `current_balance` that calls the helpers without
|
|
716
|
+
# arguments. The first pin wins, so a nested mutation keeps its caller's
|
|
717
|
+
# instant.
|
|
718
|
+
def evaluating_at(time, current_reads: false)
|
|
719
|
+
return yield if @evaluation_time
|
|
720
|
+
|
|
721
|
+
@evaluation_time = time
|
|
722
|
+
@current_reads = current_reads
|
|
723
|
+
begin
|
|
724
|
+
yield
|
|
725
|
+
ensure
|
|
726
|
+
@evaluation_time = @current_reads = nil
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# The pin a mutation takes once the wallet lock is held: one instant, and
|
|
731
|
+
# current reads exactly when this transaction's snapshot is stale.
|
|
732
|
+
def mutating_at(time, &block)
|
|
733
|
+
return yield if @evaluation_time
|
|
734
|
+
|
|
735
|
+
evaluating_at(time, current_reads: stale_snapshot?, &block)
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def evaluation_time
|
|
739
|
+
@evaluation_time || Time.current
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def current_reads?
|
|
743
|
+
@current_reads == true
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
# Whether this mutation must use locking reads to see the current ledger.
|
|
747
|
+
# Called with the wallet lock held.
|
|
748
|
+
#
|
|
749
|
+
# Only when this transaction's snapshot is stale for this wallet: every
|
|
750
|
+
# command increments the wallet's `ledger_revision` under its lock
|
|
751
|
+
# (`refresh_cached_balance!`), so when the revision this transaction's
|
|
752
|
+
# snapshot shows is the revision the lock returned, nothing was committed
|
|
753
|
+
# to this wallet's ledger after the snapshot and plain reads already see
|
|
754
|
+
# it as it is now. That covers every command that runs first in its
|
|
755
|
+
# transaction, or in a transaction that created the wallet, or that read
|
|
756
|
+
# before taking the lock without anyone writing in between. A counter,
|
|
757
|
+
# not a timestamp: no clock, `no_touching` or `touch(time:)` can make two
|
|
758
|
+
# revisions of the ledger look alike.
|
|
759
|
+
#
|
|
760
|
+
# Locking reads everywhere would be correct but not safe to run: InnoDB
|
|
761
|
+
# takes gap locks on the ranges they scan, and the gaps after a wallet's
|
|
762
|
+
# newest rows are the very gaps OTHER wallets insert into, so unrelated
|
|
763
|
+
# concurrent debits or sign-ups deadlock each other (a tenth of them in a
|
|
764
|
+
# stress run). Kept to the stale case, which needs a caller that read
|
|
765
|
+
# first AND a concurrent change to this same wallet, they are rare.
|
|
766
|
+
#
|
|
767
|
+
# A stale verdict is remembered for as long as the real database
|
|
768
|
+
# transaction lasts, savepoints and all: once this transaction writes the
|
|
769
|
+
# wallet row, its snapshot shows its own write and could no longer tell.
|
|
770
|
+
def stale_snapshot?
|
|
771
|
+
return false unless self.class.current_reads_need_locks?
|
|
772
|
+
return true if remembered_stale_snapshots.key?(stale_snapshot_key)
|
|
773
|
+
|
|
774
|
+
columns = row_marker_columns
|
|
775
|
+
snapshot = self.class.uncached { self.class.unscoped.where(id: id).pick(*columns) }
|
|
776
|
+
return false if Array.wrap(snapshot) == columns.map { |column| read_attribute(column) }
|
|
777
|
+
|
|
778
|
+
remember_stale_snapshot!
|
|
779
|
+
true
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# What tells one committed state of this wallet's ledger from another:
|
|
783
|
+
# its `ledger_revision`. See `ledger_revision?` for tables without one.
|
|
784
|
+
def row_marker_columns
|
|
785
|
+
ledger_revision? ? %w[ledger_revision] : %w[updated_at balance]
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# Whether the wallet table has `ledger_revision`, as every 0.4 schema
|
|
789
|
+
# does. The counter is the robust marker. A ledger without holds on an
|
|
790
|
+
# embedder's 0.3-era table falls back to a strictly increasing
|
|
791
|
+
# `updated_at` (see `next_row_marker`) compared together with `balance`:
|
|
792
|
+
# it keeps those schemas working, but another connection's
|
|
793
|
+
# `touch(time:)` can still spoof it, so embedders should add the column
|
|
794
|
+
# (bigint, not null, default 0). A ledger that keeps holds refuses to run
|
|
795
|
+
# without it, like a ledger whose holds table is missing: a spoofed
|
|
796
|
+
# marker could let a stale read spend held value.
|
|
797
|
+
def ledger_revision?
|
|
798
|
+
return true if has_attribute?(:ledger_revision)
|
|
799
|
+
return false unless holds_enabled?
|
|
800
|
+
|
|
801
|
+
raise Error, "#{self.class.name} keeps holds, so its wallet table needs a ledger_revision column (bigint, not null, default 0)"
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Remembers the verdict with the real transaction it belongs to. The
|
|
805
|
+
# hook at that transaction's end is only the cleanup: Active Record skips
|
|
806
|
+
# the remaining commit hooks when an earlier one raises (a host model's
|
|
807
|
+
# `after_commit`, say), and a verdict outliving its transaction would make
|
|
808
|
+
# every later one on this connection lock for nothing. So a verdict whose
|
|
809
|
+
# transaction has ended counts as gone, whether or not its hook ran.
|
|
810
|
+
def remember_stale_snapshot!
|
|
811
|
+
remembered = remembered_stale_snapshots
|
|
812
|
+
key = stale_snapshot_key
|
|
813
|
+
return if remembered.key?(key)
|
|
814
|
+
|
|
815
|
+
connection = self.class.connection
|
|
816
|
+
remembered[key] = real_transaction(connection)
|
|
817
|
+
at_end_of_real_transaction(connection) { remembered.delete(key) }
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# Per thread, and per connection inside it: a verdict is about one
|
|
821
|
+
# connection's database transaction, never another's. Each maps to the
|
|
822
|
+
# real transaction it was reached in, and one whose transaction has ended
|
|
823
|
+
# is dropped on the way out.
|
|
824
|
+
def remembered_stale_snapshots
|
|
825
|
+
remembered = ActiveSupport::IsolatedExecutionState[:wallets_stale_snapshots] ||= {}
|
|
826
|
+
remembered.delete_if { |_key, transaction| transaction&.state&.finalized? }
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# The outermost transaction open on `connection`: the real database
|
|
830
|
+
# transaction, whose snapshot a verdict is about. Active Record keeps no
|
|
831
|
+
# public pointer from a savepoint to its parent, so this reads the
|
|
832
|
+
# transaction manager's stack, an internal structure (the same on Rails
|
|
833
|
+
# 7.2 and 8.1). Its state is public: `finalized?` once it commits or
|
|
834
|
+
# rolls back, on both versions (7.2's `open?` stays true to the end, so
|
|
835
|
+
# it cannot tell). Should a later Rails change the stack, this answers
|
|
836
|
+
# nil, and the verdict then lasts until its hook ends it, exactly as long
|
|
837
|
+
# as it used to: never shorter, which would read a stale snapshot. The
|
|
838
|
+
# current transaction is no substitute, as it may be a savepoint that
|
|
839
|
+
# ends long before the real transaction does.
|
|
840
|
+
def real_transaction(connection)
|
|
841
|
+
manager = connection.transaction_manager
|
|
842
|
+
return unless manager.instance_variable_defined?(:@stack)
|
|
843
|
+
|
|
844
|
+
manager.instance_variable_get(:@stack).first
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def stale_snapshot_key
|
|
848
|
+
[self.class.connection.object_id, self.class.table_name, id]
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Runs the block once the real database transaction open on `connection`
|
|
852
|
+
# ends, by commit or by rollback, however deep in savepoints this is
|
|
853
|
+
# called. A savepoint's own hooks are not enough: one released under a
|
|
854
|
+
# `transaction(joinable: false)` runs its commit hooks at once, with the
|
|
855
|
+
# real transaction (and its snapshot) still open. So each hook hands the
|
|
856
|
+
# block on to the next transaction out, until none is left.
|
|
857
|
+
#
|
|
858
|
+
# The hooks go on Active Record's internal transaction, not on the public
|
|
859
|
+
# `ActiveRecord::Transaction` it exposes: a `transaction(joinable: false)`
|
|
860
|
+
# exposes the null transaction, whose `after_commit` runs at once, and
|
|
861
|
+
# would end a verdict while its transaction still runs.
|
|
862
|
+
def at_end_of_real_transaction(connection, &block)
|
|
863
|
+
transaction = connection.current_transaction
|
|
864
|
+
return yield unless transaction.open?
|
|
865
|
+
|
|
866
|
+
hand_on = -> { at_end_of_real_transaction(connection, &block) }
|
|
867
|
+
transaction.after_commit(&hand_on)
|
|
868
|
+
transaction.after_rollback(&hand_on)
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# Turns a read a mutation decides on into a current read (see
|
|
872
|
+
# `stale_snapshot?`). Public readers never lock.
|
|
873
|
+
def current_read(relation, lock)
|
|
874
|
+
return relation unless lock && self.class.current_reads_need_locks?
|
|
875
|
+
|
|
876
|
+
relation.lock("FOR UPDATE")
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# The same rule for a raw subquery inside a locked statement: MySQL locks
|
|
880
|
+
# (and reads current) rows of a subquery only when the subquery itself
|
|
881
|
+
# says FOR UPDATE.
|
|
882
|
+
def locking_clause(lock)
|
|
883
|
+
return "" unless lock && self.class.current_reads_need_locks?
|
|
884
|
+
|
|
885
|
+
" FOR UPDATE"
|
|
347
886
|
end
|
|
348
887
|
|
|
349
888
|
# =========================================
|
|
350
889
|
# Credit/Debit Implementation
|
|
351
890
|
# =========================================
|
|
352
891
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
892
|
+
# `hold:` (category, releases_at, metadata) places a hold on the new
|
|
893
|
+
# credit before anything reads the balance again, so no snapshot, cache
|
|
894
|
+
# or callback ever sees the value spendable. A credit never lowers the
|
|
895
|
+
# available balance (a hold on arrival covers only the credit itself), so
|
|
896
|
+
# it can cross no threshold.
|
|
897
|
+
def apply_credit(amount, metadata:, category:, expires_at:, transfer:, extra_attributes: {}, hold: nil, at: evaluation_time)
|
|
898
|
+
mutating_at(at) do
|
|
899
|
+
amount = normalize_positive_amount!(amount)
|
|
900
|
+
validate_expiration!(expires_at, at: evaluation_time)
|
|
901
|
+
ensure_deadline_ahead!(hold[:releases_at]) if hold
|
|
356
902
|
|
|
357
|
-
|
|
903
|
+
previous_balance = balance
|
|
358
904
|
|
|
359
|
-
|
|
360
|
-
|
|
905
|
+
transaction = transactions.create!(
|
|
906
|
+
{
|
|
907
|
+
amount: amount,
|
|
908
|
+
category: category,
|
|
909
|
+
expires_at: expires_at,
|
|
910
|
+
metadata: metadata,
|
|
911
|
+
transfer: transfer
|
|
912
|
+
}.merge(extra_attributes)
|
|
913
|
+
)
|
|
914
|
+
arrival_hold = hold_class.create!(wallet: self, credit: transaction, **hold) if hold
|
|
915
|
+
|
|
916
|
+
new_balance = refresh_cached_balance!
|
|
917
|
+
transaction.sync_balance_snapshot!(before: previous_balance, after: new_balance)
|
|
918
|
+
|
|
919
|
+
dispatch_callback(:credited,
|
|
920
|
+
wallet: self,
|
|
361
921
|
amount: amount,
|
|
362
922
|
category: category,
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
refresh_cached_balance!
|
|
370
|
-
transaction.sync_balance_snapshot!(before: previous_balance, after: balance)
|
|
923
|
+
transaction: transaction,
|
|
924
|
+
previous_balance: previous_balance,
|
|
925
|
+
new_balance: new_balance,
|
|
926
|
+
metadata: metadata)
|
|
927
|
+
dispatch_hold_created!(arrival_hold, previous_balance: previous_balance, new_balance: new_balance) if arrival_hold
|
|
371
928
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
category: category,
|
|
376
|
-
transaction: transaction,
|
|
377
|
-
previous_balance: previous_balance,
|
|
378
|
-
new_balance: balance,
|
|
379
|
-
metadata: metadata
|
|
380
|
-
)
|
|
929
|
+
transaction
|
|
930
|
+
end
|
|
931
|
+
end
|
|
381
932
|
|
|
382
|
-
|
|
933
|
+
def apply_debit(...)
|
|
934
|
+
apply_debit_with_allocations(...).first
|
|
383
935
|
end
|
|
384
936
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
937
|
+
# The debit, and the allocations that fund it, each holding the very
|
|
938
|
+
# source row the allocator locked (a transfer keeps their expirations).
|
|
939
|
+
# `from:` is the credits a targeted debit names (see `named_credits`).
|
|
940
|
+
def apply_debit_with_allocations(amount, metadata:, category:, transfer:, extra_attributes: {}, allow_unbacked: allow_negative_balance?, from: nil, at: evaluation_time)
|
|
941
|
+
mutating_at(at) do
|
|
942
|
+
amount = normalize_positive_amount!(amount)
|
|
943
|
+
previous_balance = balance
|
|
944
|
+
|
|
945
|
+
buckets = named_buckets!(from, amount, previous_balance, metadata) if from
|
|
946
|
+
if from.nil? && amount > previous_balance && !allow_unbacked
|
|
947
|
+
reject_insufficient_balance!(amount, previous_balance, metadata)
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# A targeted debit is never left unbacked, whatever the flag says.
|
|
951
|
+
spend_transaction, allocations = post_debit!(
|
|
952
|
+
amount,
|
|
953
|
+
metadata: metadata,
|
|
954
|
+
category: category,
|
|
955
|
+
transfer: transfer,
|
|
956
|
+
extra_attributes: extra_attributes,
|
|
957
|
+
buckets: buckets,
|
|
958
|
+
allow_unbacked: allow_unbacked && from.nil?
|
|
959
|
+
)
|
|
388
960
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
961
|
+
new_balance = refresh_cached_balance!
|
|
962
|
+
report_debit!(spend_transaction, amount: amount, category: category, metadata: metadata, previous_balance: previous_balance, new_balance: new_balance)
|
|
963
|
+
dispatch_balance_threshold_callbacks!(previous_balance, new_balance)
|
|
964
|
+
|
|
965
|
+
[spend_transaction, allocations]
|
|
392
966
|
end
|
|
967
|
+
end
|
|
393
968
|
|
|
969
|
+
# Writes a debit and the allocations that fund it: from `buckets` when
|
|
970
|
+
# given, else from eligible credits. Whatever no bucket backs is left
|
|
971
|
+
# unbacked, which only `allow_unbacked` permits. Funding has been checked
|
|
972
|
+
# by then; this only refuses to write a debit that does not add up.
|
|
973
|
+
def post_debit!(amount, metadata:, category:, transfer:, extra_attributes: {}, buckets: nil, allow_unbacked: false)
|
|
394
974
|
spend_transaction = transactions.create!(
|
|
395
975
|
{
|
|
396
976
|
amount: -amount,
|
|
@@ -400,14 +980,20 @@ module Wallets
|
|
|
400
980
|
}.merge(extra_attributes)
|
|
401
981
|
)
|
|
402
982
|
|
|
403
|
-
|
|
983
|
+
allocations = allocate_debit!(spend_transaction, amount, buckets: buckets)
|
|
984
|
+
remaining_to_allocate = amount - allocations.sum(&:amount)
|
|
404
985
|
|
|
405
|
-
if remaining_to_allocate.positive? && !
|
|
986
|
+
if remaining_to_allocate.positive? && !allow_unbacked
|
|
406
987
|
raise InsufficientBalance, "Not enough balance buckets to cover the debit"
|
|
407
988
|
end
|
|
408
989
|
|
|
409
|
-
|
|
410
|
-
|
|
990
|
+
[spend_transaction, allocations]
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
# The debit's balance snapshot and its `debited` callback, from the
|
|
994
|
+
# balances before and after the whole command.
|
|
995
|
+
def report_debit!(spend_transaction, amount:, category:, metadata:, previous_balance:, new_balance:)
|
|
996
|
+
spend_transaction.sync_balance_snapshot!(before: previous_balance, after: new_balance)
|
|
411
997
|
|
|
412
998
|
dispatch_callback(:debited,
|
|
413
999
|
wallet: self,
|
|
@@ -415,42 +1001,432 @@ module Wallets
|
|
|
415
1001
|
category: category,
|
|
416
1002
|
transaction: spend_transaction,
|
|
417
1003
|
previous_balance: previous_balance,
|
|
418
|
-
new_balance:
|
|
419
|
-
metadata: metadata
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
dispatch_balance_threshold_callbacks!(previous_balance)
|
|
423
|
-
|
|
424
|
-
spend_transaction
|
|
1004
|
+
new_balance: new_balance,
|
|
1005
|
+
metadata: metadata)
|
|
425
1006
|
end
|
|
426
1007
|
|
|
427
|
-
|
|
428
|
-
|
|
1008
|
+
# Allocates `amount` of the debit to buckets and returns the allocations,
|
|
1009
|
+
# in order: the named `buckets` of a targeted debit, else every eligible
|
|
1010
|
+
# credit (see `eligible_buckets`). Whatever no bucket can back is left
|
|
1011
|
+
# unbacked.
|
|
1012
|
+
def allocate_debit!(spend_transaction, amount, buckets: nil)
|
|
1013
|
+
split_across(buckets || eligible_buckets, amount).map do |source_transaction, portion|
|
|
1014
|
+
allocation_class.create!(
|
|
1015
|
+
spend_transaction: spend_transaction,
|
|
1016
|
+
source_transaction: source_transaction,
|
|
1017
|
+
amount: portion
|
|
1018
|
+
)
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
429
1021
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
1022
|
+
# The buckets an ordinary debit walks, locked, in the order it spends
|
|
1023
|
+
# them.
|
|
1024
|
+
#
|
|
1025
|
+
# Only eligible buckets fund a debit (see `eligible_credits`).
|
|
1026
|
+
# Reservations pin no bucket: affordability enforces them, so ordinary
|
|
1027
|
+
# debits leave them backed by the longest-lived buckets.
|
|
1028
|
+
#
|
|
1029
|
+
# Soonest-expiring buckets are consumed first so expirable value never
|
|
1030
|
+
# sits unused behind evergreen value; ties (including all-evergreen
|
|
1031
|
+
# wallets) fall back to oldest-first.
|
|
1032
|
+
#
|
|
1033
|
+
# Each bucket's remaining value comes from the same locked statement
|
|
1034
|
+
# that loads it, so allocation decides on current rows (see
|
|
1035
|
+
# `current_reads_need_locks?`), never on an older snapshot.
|
|
1036
|
+
def eligible_buckets
|
|
1037
|
+
eligible_credits
|
|
1038
|
+
.select(transaction_class.arel_table[Arel.star], Arel.sql("#{remaining_amount_sql} AS allocatable_amount"))
|
|
433
1039
|
.order(Arel.sql("COALESCE(expires_at, '9999-12-31 23:59:59'), id ASC"))
|
|
434
1040
|
.lock("FOR UPDATE")
|
|
435
1041
|
.to_a
|
|
1042
|
+
end
|
|
436
1043
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
1044
|
+
# Splits `amount` across `buckets` in their order: each gives what it has
|
|
1045
|
+
# left until nothing is left to take. Returns [bucket, portion] pairs;
|
|
1046
|
+
# their sum falls short of `amount` only when the buckets run out.
|
|
1047
|
+
def split_across(buckets, amount)
|
|
1048
|
+
remaining_to_allocate = amount
|
|
440
1049
|
|
|
441
|
-
|
|
1050
|
+
buckets.each_with_object([]) do |bucket, portions|
|
|
1051
|
+
break portions unless remaining_to_allocate.positive?
|
|
442
1052
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
1053
|
+
portion = [bucket.allocatable_amount.to_i, remaining_to_allocate].min
|
|
1054
|
+
next unless portion.positive?
|
|
1055
|
+
|
|
1056
|
+
portions << [bucket, portion]
|
|
1057
|
+
remaining_to_allocate -= portion
|
|
1058
|
+
end
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
# =========================================
|
|
1062
|
+
# Targeted Debit Implementation (`from:`)
|
|
1063
|
+
# =========================================
|
|
1064
|
+
|
|
1065
|
+
# The credits `from:` names, as far as they can be checked without the
|
|
1066
|
+
# ledger: one saved credit of this ledger, or a list of distinct ones, in
|
|
1067
|
+
# the caller's order. nil when `from:` was left out. Whose they are,
|
|
1068
|
+
# whether they are credits, and what they have left to give is read under
|
|
1069
|
+
# the wallet lock (see `named_buckets!`), never taken from the caller's
|
|
1070
|
+
# copies.
|
|
1071
|
+
def named_credits(from)
|
|
1072
|
+
return if from.equal?(ELIGIBLE_CREDITS)
|
|
1073
|
+
|
|
1074
|
+
ensure_holds_enabled!
|
|
1075
|
+
raise ArgumentError, "from: is nil; leave it out to spend any eligible credit" if from.nil?
|
|
1076
|
+
|
|
1077
|
+
credits = Array.wrap(from)
|
|
1078
|
+
raise ArgumentError, "from: names no credit" if credits.empty?
|
|
1079
|
+
|
|
1080
|
+
credits.each do |credit|
|
|
1081
|
+
unless credit.instance_of?(transaction_class)
|
|
1082
|
+
raise ArgumentError, "from: takes #{transaction_class.name} credits, not #{credit.class.name}"
|
|
1083
|
+
end
|
|
1084
|
+
raise ArgumentError, "from: takes saved credits only" unless credit.persisted?
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
repeated = credits.map(&:id).tally.select { |_id, count| count > 1 }.keys
|
|
1088
|
+
raise ArgumentError, "from: names transaction #{repeated.join(", ")} more than once" if repeated.any?
|
|
1089
|
+
|
|
1090
|
+
credits
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
# The named credits, read again under the wallet lock in the caller's
|
|
1094
|
+
# order, once they are known to fund `amount` (D7, D8). Returns them as
|
|
1095
|
+
# the buckets to allocate from; raises otherwise, before anything is
|
|
1096
|
+
# written.
|
|
1097
|
+
#
|
|
1098
|
+
# - Every one is a credit of this wallet; anything else is a wrong
|
|
1099
|
+
# argument (ArgumentError), like `hold` draws it.
|
|
1100
|
+
# - A credit expired at this instant has nothing left to give, exactly
|
|
1101
|
+
# like a spent one, so it is passed over and never allocated from:
|
|
1102
|
+
# handing on its value would create value that no longer exists,
|
|
1103
|
+
# whatever a transfer's expiration policy.
|
|
1104
|
+
# - Together the rest hold at least `amount`: a targeted debit is
|
|
1105
|
+
# allocated to them in full, never to other credits and never to debt.
|
|
1106
|
+
# - With overdrafts off, the part they give from unheld value must fit in
|
|
1107
|
+
# the available balance, floored at zero. Held value is not part of
|
|
1108
|
+
# the available balance, so taking it needs no funding; unheld value
|
|
1109
|
+
# is, so naming an unheld credit can neither spend what a reservation
|
|
1110
|
+
# set aside nor deepen a deficit.
|
|
1111
|
+
def named_buckets!(credits, amount, available, metadata)
|
|
1112
|
+
buckets = locked_named_buckets(credits)
|
|
1113
|
+
portions = split_across(buckets, amount)
|
|
1114
|
+
found = portions.sum { |_bucket, portion| portion }
|
|
1115
|
+
covered_consumed = portions.sum { |bucket, portion| covered_bucket?(bucket) ? portion : 0 }
|
|
1116
|
+
|
|
1117
|
+
if found < amount
|
|
1118
|
+
reject_insufficient_balance!(amount, available, metadata,
|
|
1119
|
+
comparison: "#{found} < #{amount} in the named credits",
|
|
1120
|
+
covered_consumed: covered_consumed)
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
needed = amount - covered_consumed
|
|
1124
|
+
if needed > [available, 0].max && !allow_negative_balance?
|
|
1125
|
+
reject_insufficient_balance!(amount, available, metadata,
|
|
1126
|
+
comparison: covered_consumed.zero? ? "#{available} < #{amount}" : "#{available} < #{amount} - #{covered_consumed} from held credits",
|
|
1127
|
+
covered_consumed: covered_consumed)
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
buckets
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
# One locked statement, like the buckets an ordinary debit walks: each
|
|
1134
|
+
# named credit with its remaining value and whether an active hold covers
|
|
1135
|
+
# it, all at this instant and as the rows are now. The expired ones are
|
|
1136
|
+
# left out: their remaining value is what they held, not what they can
|
|
1137
|
+
# still give.
|
|
1138
|
+
def locked_named_buckets(credits)
|
|
1139
|
+
at = evaluation_time
|
|
1140
|
+
rows = transactions.where(id: credits.map(&:id))
|
|
1141
|
+
.select(
|
|
1142
|
+
transaction_class.arel_table[Arel.star],
|
|
1143
|
+
Arel.sql("#{remaining_amount_sql} AS allocatable_amount"),
|
|
1144
|
+
Arel.sql("CASE WHEN #{self.class.coverage_sql(at: at, lock: current_reads?)} THEN 1 ELSE 0 END AS covered_bucket")
|
|
447
1145
|
)
|
|
1146
|
+
.lock("FOR UPDATE")
|
|
1147
|
+
.index_by(&:id)
|
|
448
1148
|
|
|
449
|
-
|
|
450
|
-
|
|
1149
|
+
credits.filter_map do |credit|
|
|
1150
|
+
row = rows[credit.id] || raise(ArgumentError, missing_credit_message(credit))
|
|
1151
|
+
raise ArgumentError, "Transaction #{credit.id} is a debit: only credits can fund a targeted debit" unless row.credit?
|
|
1152
|
+
|
|
1153
|
+
row unless row.expired?(at: at)
|
|
451
1154
|
end
|
|
1155
|
+
end
|
|
452
1156
|
|
|
453
|
-
|
|
1157
|
+
# Read from the flag `locked_named_buckets` selects with each row.
|
|
1158
|
+
def covered_bucket?(bucket)
|
|
1159
|
+
bucket.read_attribute(:covered_bucket).to_i == 1
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
def missing_credit_message(credit)
|
|
1163
|
+
if transaction_class.where(id: credit.id).exists?
|
|
1164
|
+
"Transaction #{credit.id} belongs to another wallet"
|
|
1165
|
+
else
|
|
1166
|
+
"Transaction #{credit.id} no longer exists"
|
|
1167
|
+
end
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
# =========================================
|
|
1171
|
+
# Hold Implementation
|
|
1172
|
+
# =========================================
|
|
1173
|
+
|
|
1174
|
+
# Writes a hold and reports it: one cache refresh, `hold_created`, then
|
|
1175
|
+
# the threshold crossings from the balances before and after. A hold can
|
|
1176
|
+
# lower the available balance (a credit hold, a wallet hold, a
|
|
1177
|
+
# reservation) or leave it where it was (another reason on value already
|
|
1178
|
+
# held), and only a real crossing is reported.
|
|
1179
|
+
def place_hold!(previous_balance: balance, **attributes)
|
|
1180
|
+
hold = hold_class.create!(wallet: self, **attributes)
|
|
1181
|
+
new_balance = refresh_cached_balance!
|
|
1182
|
+
|
|
1183
|
+
dispatch_hold_created!(hold, previous_balance: previous_balance, new_balance: new_balance)
|
|
1184
|
+
dispatch_balance_threshold_callbacks!(previous_balance, new_balance)
|
|
1185
|
+
|
|
1186
|
+
hold
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
# The wallet side of `Hold#release!`. The wallet is locked first, like in
|
|
1190
|
+
# every command, and only then is the hold read, so concurrent releases
|
|
1191
|
+
# (or a release racing a capture) queue on the wallet and every one after
|
|
1192
|
+
# the first finds the hold already ended. Releasing gives value back and
|
|
1193
|
+
# can never cross a low-balance threshold, so none is evaluated.
|
|
1194
|
+
def release_hold!(hold, reason:, metadata:)
|
|
1195
|
+
metadata = normalize_metadata(metadata)
|
|
1196
|
+
|
|
1197
|
+
ending_hold(hold) do
|
|
1198
|
+
mutating_at(Time.current) do
|
|
1199
|
+
# A locking read: the hold as it is now, whatever an earlier read in
|
|
1200
|
+
# this transaction saw.
|
|
1201
|
+
hold.lock!
|
|
1202
|
+
|
|
1203
|
+
if hold.active?(at: evaluation_time)
|
|
1204
|
+
previous_balance = balance
|
|
1205
|
+
hold.update!(released_at: evaluation_time, release_reason: reason, release_metadata: metadata)
|
|
1206
|
+
new_balance = refresh_cached_balance!
|
|
1207
|
+
|
|
1208
|
+
dispatch_hold_released!(hold, previous_balance: previous_balance, new_balance: new_balance)
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1211
|
+
hold
|
|
1212
|
+
end
|
|
1213
|
+
end
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
# Runs a command that ends `hold`, a release or a capture, under the
|
|
1217
|
+
# wallet lock and in its own savepoint. Should it fail, the caller's copy
|
|
1218
|
+
# of the hold is read back from the ledger: Active Record leaves a record
|
|
1219
|
+
# holding the values a rolled-back write gave it, so the copy would
|
|
1220
|
+
# otherwise go on reading released (or captured, linked to a debit that
|
|
1221
|
+
# never existed), and a retry that checks `active?` or `captured?` first
|
|
1222
|
+
# would never run. If even that read fails, the original error is the
|
|
1223
|
+
# one worth raising.
|
|
1224
|
+
#
|
|
1225
|
+
# It is a current read, locking where plain reads may answer from an
|
|
1226
|
+
# older snapshot (MySQL, see `current_reads_need_locks?`). A plain one
|
|
1227
|
+
# there would undo what the command's own locking read had just shown:
|
|
1228
|
+
# a capture refused because another connection captured first would
|
|
1229
|
+
# leave the copy reading uncaptured again. It locks the hold's row by
|
|
1230
|
+
# its primary key, no range, until the caller's transaction ends.
|
|
1231
|
+
def ending_hold(hold, &block)
|
|
1232
|
+
with_lock(requires_new: true, &block)
|
|
1233
|
+
rescue => error
|
|
1234
|
+
begin
|
|
1235
|
+
hold.reload(lock: self.class.current_reads_need_locks?)
|
|
1236
|
+
rescue ActiveRecord::ActiveRecordError
|
|
1237
|
+
nil
|
|
1238
|
+
end
|
|
1239
|
+
raise error
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
# The wallet side of `Hold#capture!`: one composite step (D9). The wallet
|
|
1243
|
+
# is locked first and the reservation read as it is now, exactly like a
|
|
1244
|
+
# release, so a capture racing a release or another capture finds the
|
|
1245
|
+
# reservation already ended and creates nothing.
|
|
1246
|
+
#
|
|
1247
|
+
# The reservation becomes the debit: the amount it set aside funds its
|
|
1248
|
+
# own capture, while every other reservation stays fully in force. The
|
|
1249
|
+
# debit spends eligible credits only, like any ordinary debit, so value
|
|
1250
|
+
# a hold covers is never captured. The balances the debit's snapshot and
|
|
1251
|
+
# the callbacks report are the ones before and after the whole step,
|
|
1252
|
+
# never the instant in between where the reservation is gone and the
|
|
1253
|
+
# debit not yet posted: capturing a fully reserved wallet reads 0 -> 0.
|
|
1254
|
+
def capture_reservation!(reservation, amount, category:, metadata:)
|
|
1255
|
+
metadata = normalize_metadata(metadata)
|
|
1256
|
+
|
|
1257
|
+
ending_hold(reservation) do
|
|
1258
|
+
mutating_at(Time.current) do
|
|
1259
|
+
# A locking read: the reservation as it is now, whatever an earlier
|
|
1260
|
+
# read in this transaction saw.
|
|
1261
|
+
reservation.lock!
|
|
1262
|
+
ensure_capturable!(reservation)
|
|
1263
|
+
amount = capture_amount(reservation, amount)
|
|
1264
|
+
|
|
1265
|
+
previous_balance = balance
|
|
1266
|
+
available_for_capture = previous_balance + reservation.amount
|
|
1267
|
+
|
|
1268
|
+
if amount > available_for_capture && !allow_negative_balance?
|
|
1269
|
+
reject_insufficient_balance!(amount, previous_balance, metadata,
|
|
1270
|
+
comparison: "#{available_for_capture} < #{amount} to capture reservation #{reservation.id}",
|
|
1271
|
+
available_for_capture: available_for_capture)
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
# With overdrafts on, a capture beyond the eligible credits leaves
|
|
1275
|
+
# exactly the rest unbacked; one within them leaves no debt at all,
|
|
1276
|
+
# however underfunded the other reservations end up.
|
|
1277
|
+
debit, _allocations = post_debit!(amount, metadata: metadata, category: category, transfer: nil, allow_unbacked: allow_negative_balance?)
|
|
1278
|
+
reservation.update!(released_at: evaluation_time, release_reason: "captured", capture_transaction: debit)
|
|
1279
|
+
new_balance = refresh_cached_balance!
|
|
1280
|
+
|
|
1281
|
+
report_debit!(debit, amount: amount, category: category, metadata: metadata, previous_balance: previous_balance, new_balance: new_balance)
|
|
1282
|
+
dispatch_hold_released!(reservation, previous_balance: previous_balance, new_balance: new_balance, transaction: debit)
|
|
1283
|
+
dispatch_balance_threshold_callbacks!(previous_balance, new_balance)
|
|
1284
|
+
|
|
1285
|
+
debit
|
|
1286
|
+
end
|
|
1287
|
+
end
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
# What a capture takes: the reserved amount unless told otherwise, and
|
|
1291
|
+
# never more. A positive whole number, parsed like any debit's. Read only
|
|
1292
|
+
# once the reservation is known to be capturable, so that retrying a
|
|
1293
|
+
# finished capture raises Wallets::InvalidHold whatever amount it
|
|
1294
|
+
# repeats, never an ArgumentError about the amount.
|
|
1295
|
+
def capture_amount(reservation, amount)
|
|
1296
|
+
return reservation.amount if amount.nil?
|
|
1297
|
+
|
|
1298
|
+
amount = normalize_positive_amount!(amount)
|
|
1299
|
+
if amount > reservation.amount
|
|
1300
|
+
raise ArgumentError, "Cannot capture #{amount}: reservation #{reservation.id} sets aside #{reservation.amount}"
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
amount
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
# Only an active reservation can be captured, and only once. Read at the
|
|
1307
|
+
# command's instant: a reservation is gone from its deadline on.
|
|
1308
|
+
def ensure_capturable!(reservation)
|
|
1309
|
+
unless reservation.reservation?
|
|
1310
|
+
raise InvalidHold, "Hold #{reservation.id} holds credit value: only a reservation can be captured"
|
|
1311
|
+
end
|
|
1312
|
+
if reservation.captured?
|
|
1313
|
+
raise InvalidHold, "Reservation #{reservation.id} was already captured (transaction #{reservation.capture_transaction_id})"
|
|
1314
|
+
end
|
|
1315
|
+
if reservation.released?
|
|
1316
|
+
raise InvalidHold, "Reservation #{reservation.id} was released and can no longer be captured"
|
|
1317
|
+
end
|
|
1318
|
+
unless reservation.active?(at: evaluation_time)
|
|
1319
|
+
raise InvalidHold, "Reservation #{reservation.id} lapsed at #{reservation.releases_at.iso8601} and can no longer be captured"
|
|
1320
|
+
end
|
|
1321
|
+
end
|
|
1322
|
+
|
|
1323
|
+
# A credit can be held when it is a saved credit of this very wallet and
|
|
1324
|
+
# ledger, still unexpired at the command's instant, with value left to
|
|
1325
|
+
# hold. Read from the database under the wallet lock, never from the
|
|
1326
|
+
# caller's copy, so a debit that just spent it is seen.
|
|
1327
|
+
#
|
|
1328
|
+
# The same line as `from:` draws: anything that is not a credit of this
|
|
1329
|
+
# wallet is a wrong argument (ArgumentError); a credit of this wallet
|
|
1330
|
+
# that the ledger's state rules out, expired or spent at this instant,
|
|
1331
|
+
# is a hold that cannot be placed now (InvalidHold).
|
|
1332
|
+
def ensure_holdable!(credit)
|
|
1333
|
+
unless credit.instance_of?(transaction_class)
|
|
1334
|
+
raise ArgumentError, "A hold covers a #{transaction_class.name}, not #{credit.class.name}"
|
|
1335
|
+
end
|
|
1336
|
+
raise ArgumentError, "Only a saved credit can be held" unless credit.persisted?
|
|
1337
|
+
|
|
1338
|
+
row = current_read(transaction_class.where(id: credit.id), current_reads?)
|
|
1339
|
+
.pick(:wallet_id, :amount, :expires_at, Arel.sql(remaining_amount_sql))
|
|
1340
|
+
raise ArgumentError, "Transaction #{credit.id} no longer exists" if row.nil?
|
|
1341
|
+
|
|
1342
|
+
wallet_id, amount, expires_at, remaining = row
|
|
1343
|
+
raise ArgumentError, "Transaction #{credit.id} belongs to another wallet" unless wallet_id == id
|
|
1344
|
+
raise ArgumentError, "Transaction #{credit.id} is a debit: only credits can be held" unless amount.positive?
|
|
1345
|
+
raise InvalidHold, "Credit #{credit.id} has expired" if expires_at.present? && expires_at <= evaluation_time
|
|
1346
|
+
raise InvalidHold, "Credit #{credit.id} has no remaining value to hold" unless remaining.to_i.positive?
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
# The hold a credit arrives under, from `hold:` or `available_at:`,
|
|
1350
|
+
# checked before anything is written. `hold:` is a category, or a Hash
|
|
1351
|
+
# with the same keywords `hold` takes.
|
|
1352
|
+
def arrival_hold_attributes(hold, available_at)
|
|
1353
|
+
return if hold.nil? && available_at.nil?
|
|
1354
|
+
raise ArgumentError, "Pass hold: or available_at:, not both" unless hold.nil? || available_at.nil?
|
|
1355
|
+
|
|
1356
|
+
ensure_holds_enabled!
|
|
1357
|
+
return {category: :pending, releases_at: normalize_deadline(available_at, name: "available_at:"), metadata: {}} unless available_at.nil?
|
|
1358
|
+
|
|
1359
|
+
options =
|
|
1360
|
+
case hold
|
|
1361
|
+
when Symbol, String then {category: hold}
|
|
1362
|
+
when Hash then hold.symbolize_keys
|
|
1363
|
+
else raise ArgumentError, "hold: takes a category or a Hash of category:, until: and metadata:, not #{hold.inspect}"
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
unknown = options.keys - ARRIVAL_HOLD_OPTIONS
|
|
1367
|
+
raise ArgumentError, "Unknown hold: options: #{unknown.join(", ")}" if unknown.any?
|
|
1368
|
+
raise ArgumentError, "hold: needs a category" unless options.key?(:category)
|
|
1369
|
+
|
|
1370
|
+
{
|
|
1371
|
+
category: options[:category],
|
|
1372
|
+
releases_at: normalize_deadline(options[:until]),
|
|
1373
|
+
metadata: normalize_metadata(options.fetch(:metadata, {}))
|
|
1374
|
+
}
|
|
1375
|
+
end
|
|
1376
|
+
|
|
1377
|
+
# The instant a deadline names, read the way Active Record reads it into
|
|
1378
|
+
# a datetime column (strings and dates in Time.zone), so the instant a
|
|
1379
|
+
# command checks is the instant it stores. Anything that is not a time is
|
|
1380
|
+
# refused: stored, it would quietly mean "no deadline".
|
|
1381
|
+
def normalize_deadline(value, name: "until:")
|
|
1382
|
+
return if value.nil?
|
|
1383
|
+
|
|
1384
|
+
deadline = begin
|
|
1385
|
+
value.in_time_zone if value.respond_to?(:in_time_zone)
|
|
1386
|
+
rescue ArgumentError
|
|
1387
|
+
nil
|
|
1388
|
+
end
|
|
1389
|
+
|
|
1390
|
+
deadline || raise(ArgumentError, "#{name} must be a time, not #{value.inspect}")
|
|
1391
|
+
end
|
|
1392
|
+
|
|
1393
|
+
# A deadline already reached would make a hold that is over before it
|
|
1394
|
+
# starts: a caller error to surface, never a row to store.
|
|
1395
|
+
def ensure_deadline_ahead!(releases_at)
|
|
1396
|
+
return if releases_at.nil? || releases_at > evaluation_time
|
|
1397
|
+
|
|
1398
|
+
raise InvalidHold, "A hold's deadline must be in the future (until: #{releases_at.iso8601})"
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
# A ledger that did not opt in refuses every hold command here, before a
|
|
1402
|
+
# single query runs.
|
|
1403
|
+
def ensure_holds_enabled!
|
|
1404
|
+
self.class.hold_class
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1407
|
+
def dispatch_hold_created!(hold, previous_balance:, new_balance:)
|
|
1408
|
+
dispatch_callback(:hold_created,
|
|
1409
|
+
wallet: self,
|
|
1410
|
+
hold: hold,
|
|
1411
|
+
amount: hold.amount,
|
|
1412
|
+
category: hold.category,
|
|
1413
|
+
previous_balance: previous_balance,
|
|
1414
|
+
new_balance: new_balance,
|
|
1415
|
+
metadata: hold.metadata)
|
|
1416
|
+
end
|
|
1417
|
+
|
|
1418
|
+
# The same shape as `hold_created`, with the release metadata, and the
|
|
1419
|
+
# debit a capture posted as the `transaction`.
|
|
1420
|
+
def dispatch_hold_released!(hold, previous_balance:, new_balance:, transaction: nil)
|
|
1421
|
+
dispatch_callback(:hold_released,
|
|
1422
|
+
wallet: self,
|
|
1423
|
+
hold: hold,
|
|
1424
|
+
amount: hold.amount,
|
|
1425
|
+
category: hold.category,
|
|
1426
|
+
transaction: transaction,
|
|
1427
|
+
previous_balance: previous_balance,
|
|
1428
|
+
new_balance: new_balance,
|
|
1429
|
+
metadata: hold.release_metadata)
|
|
454
1430
|
end
|
|
455
1431
|
|
|
456
1432
|
# =========================================
|
|
@@ -490,21 +1466,23 @@ module Wallets
|
|
|
490
1466
|
value.to_s.strip.downcase
|
|
491
1467
|
end
|
|
492
1468
|
|
|
493
|
-
def build_transfer_inbound_credit_specs(transfer:,
|
|
1469
|
+
def build_transfer_inbound_credit_specs(transfer:, allocations:, amount:, expiration_policy:, expires_at:)
|
|
494
1470
|
case expiration_policy
|
|
495
1471
|
when "none"
|
|
496
|
-
[{
|
|
1472
|
+
[{amount: amount, expires_at: nil}]
|
|
497
1473
|
when "fixed"
|
|
498
|
-
[{
|
|
1474
|
+
[{amount: amount, expires_at: expires_at}]
|
|
499
1475
|
when "preserve"
|
|
500
|
-
build_preserved_transfer_inbound_credit_specs(transfer,
|
|
1476
|
+
build_preserved_transfer_inbound_credit_specs(transfer, allocations, amount)
|
|
501
1477
|
else
|
|
502
1478
|
raise ArgumentError, "Unsupported transfer expiration policy: #{expiration_policy}"
|
|
503
1479
|
end
|
|
504
1480
|
end
|
|
505
1481
|
|
|
506
|
-
|
|
507
|
-
|
|
1482
|
+
# Built from the allocations as the allocator made them, each holding the
|
|
1483
|
+
# source row it locked. Never re-read: a plain read could answer from a
|
|
1484
|
+
# stale snapshot that has never seen a bucket committed after it.
|
|
1485
|
+
def build_preserved_transfer_inbound_credit_specs(transfer, allocations, amount)
|
|
508
1486
|
grouped_specs = []
|
|
509
1487
|
|
|
510
1488
|
allocations.each do |allocation|
|
|
@@ -513,7 +1491,7 @@ module Wallets
|
|
|
513
1491
|
if grouped_specs.last && grouped_specs.last[:expires_at] == expires_at
|
|
514
1492
|
grouped_specs.last[:amount] += allocation.amount
|
|
515
1493
|
else
|
|
516
|
-
grouped_specs << {
|
|
1494
|
+
grouped_specs << {amount: allocation.amount, expires_at: expires_at}
|
|
517
1495
|
end
|
|
518
1496
|
end
|
|
519
1497
|
|
|
@@ -528,38 +1506,216 @@ module Wallets
|
|
|
528
1506
|
# =========================================
|
|
529
1507
|
# Balance Calculation
|
|
530
1508
|
# =========================================
|
|
1509
|
+
#
|
|
1510
|
+
# Every term reads the pinned mutation context (see `evaluating_at`): the
|
|
1511
|
+
# instant to evaluate at and whether to read current rows. Outside a
|
|
1512
|
+
# mutation that is Time.current and plain reads.
|
|
1513
|
+
|
|
1514
|
+
# The terms of the balance at one instant: P (the remaining value of
|
|
1515
|
+
# unexpired credits), C (the part of P an active hold covers), R (active
|
|
1516
|
+
# reservations) and N (the debt no credit backs).
|
|
1517
|
+
BalanceComponents = Struct.new(:positive, :covered, :reserved, :unbacked) do
|
|
1518
|
+
def available
|
|
1519
|
+
positive - covered - reserved - unbacked
|
|
1520
|
+
end
|
|
531
1521
|
|
|
1522
|
+
def held
|
|
1523
|
+
covered + reserved
|
|
1524
|
+
end
|
|
1525
|
+
|
|
1526
|
+
def total
|
|
1527
|
+
positive - unbacked
|
|
1528
|
+
end
|
|
1529
|
+
end
|
|
1530
|
+
private_constant :BalanceComponents
|
|
1531
|
+
|
|
1532
|
+
# All four terms from ONE statement over this wallet's transactions, with
|
|
1533
|
+
# R as a scalar subquery in the same statement. Separate statements would
|
|
1534
|
+
# each see their own snapshot under read committed (PostgreSQL's
|
|
1535
|
+
# default): a credit committed between two of them could count in P yet
|
|
1536
|
+
# miss C. One statement is one snapshot, so available, held and total
|
|
1537
|
+
# always agree. Ledgers that keep holds only: the others never query one.
|
|
1538
|
+
def balance_components
|
|
1539
|
+
at = evaluation_time
|
|
1540
|
+
lock = current_reads?
|
|
1541
|
+
credits = transaction_class.arel_table
|
|
1542
|
+
unexpired_credit = credits[:amount].gt(0).and(credits[:expires_at].eq(nil).or(credits[:expires_at].gt(at))).to_sql(transaction_class)
|
|
1543
|
+
debit = credits[:amount].lt(0).to_sql(transaction_class)
|
|
1544
|
+
remaining = remaining_amount_sql(lock: lock)
|
|
1545
|
+
reservations = current_read(wallet_holds.reservations.active(at: at), lock).select(hold_class.arel_table[:amount].sum)
|
|
1546
|
+
|
|
1547
|
+
terms = [
|
|
1548
|
+
"COALESCE(SUM(CASE WHEN #{unexpired_credit} THEN #{remaining} ELSE 0 END), 0)",
|
|
1549
|
+
"COALESCE(SUM(CASE WHEN #{unexpired_credit} AND #{self.class.coverage_sql(at: at, lock: lock)} THEN #{remaining} ELSE 0 END), 0)",
|
|
1550
|
+
"(#{reservations.to_sql})",
|
|
1551
|
+
"COALESCE(SUM(CASE WHEN #{debit} THEN #{unbacked_amount_sql(lock: lock)} ELSE 0 END), 0)"
|
|
1552
|
+
]
|
|
1553
|
+
|
|
1554
|
+
# An unsaved wallet has no ledger to read: every term is zero.
|
|
1555
|
+
values = current_read(transactions, lock).pick(*terms.map { |sql| Arel.sql(sql) }) || []
|
|
1556
|
+
BalanceComponents.new(*Array.new(terms.size) { |index| values[index].to_i })
|
|
1557
|
+
end
|
|
1558
|
+
|
|
1559
|
+
# P: the remaining value of unexpired credits, held or not. Kept with its
|
|
1560
|
+
# 0.3 signature and meaning (D12): embedders override it and call it.
|
|
532
1561
|
def positive_remaining_balance
|
|
533
|
-
|
|
534
|
-
|
|
1562
|
+
current_read(transactions.credits.not_expired(at: evaluation_time), current_reads?)
|
|
1563
|
+
.sum(remaining_amount_sql)
|
|
1564
|
+
.to_i
|
|
1565
|
+
end
|
|
535
1566
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
1567
|
+
# N: the part of debits no credit backs. Kept with its 0.3 signature and
|
|
1568
|
+
# meaning (D12): usage_credits passes its own scope of debits.
|
|
1569
|
+
def unbacked_negative_balance(transaction_scope = transactions.debits)
|
|
1570
|
+
current_read(transaction_scope, current_reads?)
|
|
1571
|
+
.sum(unbacked_amount_sql)
|
|
540
1572
|
.to_i
|
|
541
1573
|
end
|
|
542
1574
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
1575
|
+
# C and R, for telling a refused caller what holds keep out of reach.
|
|
1576
|
+
def held_and_reserved
|
|
1577
|
+
return [0, 0] unless holds_enabled?
|
|
1578
|
+
|
|
1579
|
+
components = balance_components
|
|
1580
|
+
[components.covered, components.reserved]
|
|
1581
|
+
end
|
|
1582
|
+
|
|
1583
|
+
# The buckets a debit may consume now: unexpired, and not covered by an
|
|
1584
|
+
# active hold (none at all while a wallet hold is active). The one
|
|
1585
|
+
# eligibility definition behind both affordability (through C) and
|
|
1586
|
+
# allocation, so the two can never disagree.
|
|
1587
|
+
def eligible_credits
|
|
1588
|
+
credits = transactions.credits.not_expired(at: evaluation_time)
|
|
1589
|
+
return credits unless holds_enabled?
|
|
1590
|
+
|
|
1591
|
+
credits.where("NOT #{self.class.coverage_sql(at: evaluation_time, lock: current_reads?)}")
|
|
1592
|
+
end
|
|
546
1593
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1594
|
+
# P - C: what FEFO allocation can take from eligible buckets right now.
|
|
1595
|
+
def eligible_remaining_balance
|
|
1596
|
+
current_read(eligible_credits, current_reads?)
|
|
1597
|
+
.sum(remaining_amount_sql)
|
|
550
1598
|
.to_i
|
|
551
1599
|
end
|
|
552
1600
|
|
|
1601
|
+
# A credit's remaining value: its amount minus what debits allocated from
|
|
1602
|
+
# it.
|
|
1603
|
+
def remaining_amount_sql(lock: current_reads?)
|
|
1604
|
+
connection = transaction_class.connection
|
|
1605
|
+
txn_table = connection.quote_table_name(transaction_class.table_name)
|
|
1606
|
+
alloc_table = connection.quote_table_name(allocation_class.table_name)
|
|
1607
|
+
amount_column = connection.quote_column_name("amount")
|
|
1608
|
+
id_column = connection.quote_column_name("id")
|
|
1609
|
+
source_transaction_id_column = connection.quote_column_name("source_transaction_id")
|
|
1610
|
+
|
|
1611
|
+
"#{txn_table}.#{amount_column} - (SELECT COALESCE(SUM(#{amount_column}), 0) FROM #{alloc_table} WHERE #{source_transaction_id_column} = #{txn_table}.#{id_column}#{locking_clause(lock)})"
|
|
1612
|
+
end
|
|
1613
|
+
|
|
1614
|
+
# A debit's unbacked value: its size minus what credits allocated to it.
|
|
1615
|
+
def unbacked_amount_sql(lock: current_reads?)
|
|
1616
|
+
connection = transaction_class.connection
|
|
1617
|
+
txn_table = connection.quote_table_name(transaction_class.table_name)
|
|
1618
|
+
alloc_table = connection.quote_table_name(allocation_class.table_name)
|
|
1619
|
+
amount_column = connection.quote_column_name("amount")
|
|
1620
|
+
id_column = connection.quote_column_name("id")
|
|
1621
|
+
transaction_id_column = connection.quote_column_name("transaction_id")
|
|
1622
|
+
|
|
1623
|
+
"ABS(#{txn_table}.#{amount_column}) - (SELECT COALESCE(SUM(#{amount_column}), 0) FROM #{alloc_table} WHERE #{transaction_id_column} = #{txn_table}.#{id_column}#{locking_clause(lock)})"
|
|
1624
|
+
end
|
|
1625
|
+
|
|
1626
|
+
def wallet_holds
|
|
1627
|
+
hold_class.where(wallet_id: id)
|
|
1628
|
+
end
|
|
1629
|
+
|
|
1630
|
+
# Writes the available balance to the cached column and returns it. Like
|
|
1631
|
+
# any cache it can go stale through time alone (expiry, lapsed deadlines),
|
|
1632
|
+
# so it is never authoritative for accounting.
|
|
1633
|
+
#
|
|
1634
|
+
# Every command increments `ledger_revision`, even when the balance did
|
|
1635
|
+
# not move (a second reason on a held credit, a capture of a whole
|
|
1636
|
+
# reservation): every committed change to the ledger must change the
|
|
1637
|
+
# wallet row, which is how `stale_snapshot?` knows a snapshot is current
|
|
1638
|
+
# (and how PostgreSQL's repeatable read detects a concurrent update on
|
|
1639
|
+
# `lock!`). `updated_at` then follows ordinary Rails rules and decides
|
|
1640
|
+
# nothing. A wallet table without the column (see `ledger_revision?`)
|
|
1641
|
+
# gets the timestamp marker instead, written with `update_column` when
|
|
1642
|
+
# the balance did not move, because `touch` writes nothing under
|
|
1643
|
+
# `no_touching`.
|
|
1644
|
+
#
|
|
1645
|
+
# `update!` rather than assigning and then saving: if the command's
|
|
1646
|
+
# savepoint rolls back afterwards, Active Record leaves the wallet with no
|
|
1647
|
+
# pending changes, so its next command can lock it. It is clean, not
|
|
1648
|
+
# restored: it keeps the rolled-back values in memory until that lock
|
|
1649
|
+
# reloads the row. Assigning first would leave them pending instead, and
|
|
1650
|
+
# no later command could lock the wallet.
|
|
553
1651
|
def refresh_cached_balance!
|
|
554
|
-
|
|
555
|
-
|
|
1652
|
+
available = current_balance
|
|
1653
|
+
|
|
1654
|
+
refreshing_cached_balance do
|
|
1655
|
+
if ledger_revision?
|
|
1656
|
+
update!(balance: available, ledger_revision: ledger_revision + 1)
|
|
1657
|
+
elsif available == read_attribute(:balance)
|
|
1658
|
+
update_column(:updated_at, next_row_marker)
|
|
1659
|
+
else
|
|
1660
|
+
update!(balance: available, updated_at: next_row_marker)
|
|
1661
|
+
end
|
|
1662
|
+
end
|
|
1663
|
+
|
|
1664
|
+
available
|
|
1665
|
+
end
|
|
1666
|
+
|
|
1667
|
+
# The timestamp marker of a wallet table without `ledger_revision`: the
|
|
1668
|
+
# command's instant, or one microsecond past the current `updated_at`
|
|
1669
|
+
# when the clock has not moved past it (two commands within a
|
|
1670
|
+
# microsecond, a frozen clock), so no two commits leave the same row
|
|
1671
|
+
# behind. Assumes the column keeps microseconds, as `t.timestamps` does
|
|
1672
|
+
# from Rails 7 on (datetime(6)).
|
|
1673
|
+
def next_row_marker
|
|
1674
|
+
now = evaluation_time
|
|
1675
|
+
now = now.change(usec: now.usec) # the column's precision
|
|
1676
|
+
return now if updated_at.nil?
|
|
1677
|
+
|
|
1678
|
+
[now, updated_at + MARKER_STEP].max
|
|
1679
|
+
end
|
|
1680
|
+
|
|
1681
|
+
def refreshing_cached_balance
|
|
1682
|
+
@refreshing_cached_balance = true
|
|
1683
|
+
yield
|
|
1684
|
+
ensure
|
|
1685
|
+
@refreshing_cached_balance = false
|
|
1686
|
+
end
|
|
1687
|
+
|
|
1688
|
+
def remember_own_write
|
|
1689
|
+
return if @refreshing_cached_balance || !self.class.current_reads_need_locks?
|
|
1690
|
+
|
|
1691
|
+
remember_stale_snapshot!
|
|
556
1692
|
end
|
|
557
1693
|
|
|
558
1694
|
# =========================================
|
|
559
1695
|
# Threshold Callbacks
|
|
560
1696
|
# =========================================
|
|
561
1697
|
|
|
562
|
-
|
|
1698
|
+
# A refused funding attempt reports once, immediately (its transaction is
|
|
1699
|
+
# about to roll back), and names what holds keep out of reach, so the app
|
|
1700
|
+
# can say "20 available, 30 held, 50 reserved" rather than just "20".
|
|
1701
|
+
#
|
|
1702
|
+
# `available` is always the wallet's available balance, which nothing
|
|
1703
|
+
# moved. A command funded by something else says what it compared in
|
|
1704
|
+
# `comparison` (a capture counts its own reservation back in) and adds
|
|
1705
|
+
# the figures behind it to the metadata (`diagnostics`).
|
|
1706
|
+
def reject_insufficient_balance!(amount, available, metadata, comparison: "#{available} < #{amount}", **diagnostics)
|
|
1707
|
+
held, reserved = held_and_reserved
|
|
1708
|
+
dispatch_insufficient_balance!(amount, available, metadata, held: held, reserved: reserved, **diagnostics)
|
|
1709
|
+
|
|
1710
|
+
details = {held: held, reserved: reserved}.reject { |_name, value| value.zero? }.map { |name, value| "#{value} #{name}" }
|
|
1711
|
+
raise InsufficientBalance, "Insufficient balance (#{comparison}#{"; #{details.join(", ")}" if details.any?})"
|
|
1712
|
+
end
|
|
1713
|
+
|
|
1714
|
+
# `held:` is the credit value holds cover and `reserved:` the active
|
|
1715
|
+
# reservations, so available + held + reserved is the total balance.
|
|
1716
|
+
def dispatch_insufficient_balance!(amount, previous_balance, metadata, held: nil, reserved: nil, **diagnostics)
|
|
1717
|
+
held, reserved = held_and_reserved if held.nil? || reserved.nil?
|
|
1718
|
+
|
|
563
1719
|
dispatch_callback(:insufficient,
|
|
564
1720
|
wallet: self,
|
|
565
1721
|
amount: amount,
|
|
@@ -567,35 +1723,42 @@ module Wallets
|
|
|
567
1723
|
new_balance: previous_balance,
|
|
568
1724
|
metadata: metadata.merge(
|
|
569
1725
|
available: previous_balance,
|
|
570
|
-
required: amount
|
|
571
|
-
|
|
572
|
-
|
|
1726
|
+
required: amount,
|
|
1727
|
+
held: held,
|
|
1728
|
+
reserved: reserved,
|
|
1729
|
+
**diagnostics
|
|
1730
|
+
))
|
|
573
1731
|
end
|
|
574
1732
|
|
|
575
|
-
def dispatch_balance_threshold_callbacks!(previous_balance)
|
|
576
|
-
if !was_low_balance?(previous_balance) && low_balance?
|
|
1733
|
+
def dispatch_balance_threshold_callbacks!(previous_balance, new_balance = balance)
|
|
1734
|
+
if !was_low_balance?(previous_balance) && low_balance?(new_balance)
|
|
577
1735
|
dispatch_callback(:low_balance,
|
|
578
1736
|
wallet: self,
|
|
579
1737
|
threshold: config.low_balance_threshold,
|
|
580
1738
|
previous_balance: previous_balance,
|
|
581
|
-
new_balance:
|
|
582
|
-
)
|
|
1739
|
+
new_balance: new_balance)
|
|
583
1740
|
end
|
|
584
1741
|
|
|
585
|
-
|
|
1742
|
+
# `:balance_depleted` fires when the wallet crosses from a positive
|
|
1743
|
+
# balance to ZERO OR LOWER. We use `<= 0` rather than `== 0` because
|
|
1744
|
+
# available balance can skip exact zero: an overdraft debit takes it
|
|
1745
|
+
# from +100 to -50, and holds or expiry can take it below zero even
|
|
1746
|
+
# with `allow_negative_balance` off. Either way the callback is
|
|
1747
|
+
# one-shot per crossing — going from -50 to -80 doesn't re-fire
|
|
1748
|
+
# because `previous_balance` was already non-positive.
|
|
1749
|
+
if previous_balance.positive? && !new_balance.positive?
|
|
586
1750
|
dispatch_callback(:depleted,
|
|
587
1751
|
wallet: self,
|
|
588
1752
|
previous_balance: previous_balance,
|
|
589
|
-
new_balance:
|
|
590
|
-
)
|
|
1753
|
+
new_balance: new_balance)
|
|
591
1754
|
end
|
|
592
1755
|
end
|
|
593
1756
|
|
|
594
|
-
def low_balance?
|
|
1757
|
+
def low_balance?(available = balance)
|
|
595
1758
|
threshold = config.low_balance_threshold
|
|
596
1759
|
return false if threshold.nil?
|
|
597
1760
|
|
|
598
|
-
|
|
1761
|
+
available <= threshold
|
|
599
1762
|
end
|
|
600
1763
|
|
|
601
1764
|
def was_low_balance?(previous_balance)
|
|
@@ -613,17 +1776,23 @@ module Wallets
|
|
|
613
1776
|
# Validation Helpers
|
|
614
1777
|
# =========================================
|
|
615
1778
|
|
|
616
|
-
def validate_expiration!(expires_at)
|
|
1779
|
+
def validate_expiration!(expires_at, at: Time.current)
|
|
617
1780
|
return if expires_at.nil?
|
|
618
1781
|
raise ArgumentError, "Expiration date must respond to to_datetime" unless expires_at.respond_to?(:to_datetime)
|
|
619
|
-
|
|
1782
|
+
|
|
1783
|
+
expiration = begin
|
|
1784
|
+
expires_at.to_datetime
|
|
1785
|
+
rescue
|
|
1786
|
+
raise ArgumentError, "Expiration date must be a valid date or time"
|
|
1787
|
+
end
|
|
1788
|
+
|
|
1789
|
+
raise ArgumentError, "Expiration date must be in the future" if expiration <= at
|
|
620
1790
|
end
|
|
621
1791
|
|
|
622
1792
|
def normalize_positive_amount!(amount)
|
|
623
1793
|
raise ArgumentError, "Amount is required" if amount.nil?
|
|
624
|
-
raise ArgumentError, "Amount must be a whole number" unless amount == amount.to_i
|
|
625
1794
|
|
|
626
|
-
amount = amount
|
|
1795
|
+
amount = Wallets::WholeNumber.parse(amount, name: "Amount")
|
|
627
1796
|
raise ArgumentError, "Amount must be positive" unless amount.positive?
|
|
628
1797
|
|
|
629
1798
|
amount
|
|
@@ -633,6 +1802,15 @@ module Wallets
|
|
|
633
1802
|
metadata.respond_to?(:to_h) ? metadata.to_h : {}
|
|
634
1803
|
end
|
|
635
1804
|
|
|
1805
|
+
def validate_additional_transaction_attributes!(attributes)
|
|
1806
|
+
allowed = self.class.additional_transaction_attribute_names.map(&:to_sym)
|
|
1807
|
+
requested = attributes.keys.map(&:to_sym)
|
|
1808
|
+
unsupported = (requested - allowed) | (requested & CORE_TRANSACTION_ATTRIBUTE_NAMES)
|
|
1809
|
+
return attributes if unsupported.empty?
|
|
1810
|
+
|
|
1811
|
+
raise ArgumentError, "Unsupported transaction attributes: #{unsupported.join(", ")}"
|
|
1812
|
+
end
|
|
1813
|
+
|
|
636
1814
|
def lock_wallet_pair!(other_wallet)
|
|
637
1815
|
first, second = [self, other_wallet].sort_by(&:id)
|
|
638
1816
|
first.lock!
|
|
@@ -640,15 +1818,32 @@ module Wallets
|
|
|
640
1818
|
end
|
|
641
1819
|
|
|
642
1820
|
def normalize_asset_code!
|
|
643
|
-
self.asset_code = asset_code.
|
|
1821
|
+
self.asset_code = Wallets.normalize_asset_code(asset_code).presence
|
|
644
1822
|
end
|
|
1823
|
+
end
|
|
645
1824
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
1825
|
+
# The concrete standalone ledger model (table: wallets_wallets via the
|
|
1826
|
+
# default config). Embedders subclass WalletBase instead.
|
|
1827
|
+
#
|
|
1828
|
+
# It keeps holds. Their associations live here, never on WalletBase, so a
|
|
1829
|
+
# ledger that did not opt in never touches a holds table.
|
|
1830
|
+
class Wallet < WalletBase
|
|
1831
|
+
self.hold_class_name = "Wallets::Hold"
|
|
1832
|
+
|
|
1833
|
+
has_many :holds, class_name: "Wallets::Hold", foreign_key: :wallet_id, inverse_of: :wallet
|
|
1834
|
+
|
|
1835
|
+
# Holds reference this wallet's transactions (the credit a hold covers,
|
|
1836
|
+
# the debit a reservation was captured into), so they must be destroyed
|
|
1837
|
+
# BEFORE the transaction and transfer dependencies WalletBase declares. A
|
|
1838
|
+
# `dependent: :destroy` here would be appended after those inherited
|
|
1839
|
+
# callbacks; prepending puts it first. It runs inside destroy's
|
|
1840
|
+
# transaction, so a hold that refuses to go aborts the whole destroy.
|
|
1841
|
+
before_destroy :destroy_holds, prepend: true
|
|
1842
|
+
|
|
1843
|
+
private
|
|
1844
|
+
|
|
1845
|
+
def destroy_holds
|
|
1846
|
+
holds.destroy_all
|
|
652
1847
|
end
|
|
653
1848
|
end
|
|
654
1849
|
end
|