usage_credits 0.5.0 → 1.0.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 +75 -0
- data/README.md +120 -29
- data/lib/generators/usage_credits/install_generator.rb +9 -1
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +64 -20
- data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
- data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
- data/lib/usage_credits/callbacks.rb +9 -54
- data/lib/usage_credits/configuration.rb +25 -13
- data/lib/usage_credits/core_ext/numeric.rb +12 -16
- data/lib/usage_credits/cost/base.rb +1 -6
- data/lib/usage_credits/cost/compound.rb +3 -2
- data/lib/usage_credits/cost/fixed.rb +1 -2
- data/lib/usage_credits/cost/variable.rb +23 -8
- data/lib/usage_credits/engine.rb +8 -12
- data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
- data/lib/usage_credits/helpers/credits_helper.rb +9 -11
- data/lib/usage_credits/helpers/period_parser.rb +31 -9
- data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
- data/lib/usage_credits/models/allocation.rb +15 -20
- data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
- data/lib/usage_credits/models/credit_pack.rb +34 -13
- data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
- data/lib/usage_credits/models/fulfillment.rb +23 -48
- data/lib/usage_credits/models/operation.rb +36 -28
- data/lib/usage_credits/models/transaction.rb +51 -158
- data/lib/usage_credits/models/transfer.rb +37 -0
- data/lib/usage_credits/models/wallet.rb +224 -312
- data/lib/usage_credits/railtie.rb +4 -13
- data/lib/usage_credits/services/fulfillment_service.rb +146 -22
- data/lib/usage_credits/subscription_terms.rb +131 -0
- data/lib/usage_credits/version.rb +1 -1
- data/lib/usage_credits.rb +16 -3
- metadata +33 -20
- data/.rubocop.yml +0 -8
- data/.simplecov +0 -37
- data/AGENTS.md +0 -5
- data/Appraisals +0 -43
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -32
- data/gemfiles/pay_10.0.gemfile +0 -41
- data/gemfiles/pay_11.0.gemfile +0 -41
- data/gemfiles/pay_8.3.gemfile +0 -41
- data/gemfiles/pay_9.0.gemfile +0 -41
- data/gemfiles/rails_7.2.gemfile +0 -41
- data/gemfiles/rails_8.1.gemfile +0 -41
|
@@ -3,106 +3,118 @@
|
|
|
3
3
|
module UsageCredits
|
|
4
4
|
# A Wallet manages credit balance and transactions for a user/owner.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
class Wallet < ApplicationRecord
|
|
13
|
-
self.table_name = "usage_credits_wallets"
|
|
6
|
+
# This class extends Wallets::Wallet with usage_credits-specific features:
|
|
7
|
+
# - Operation-based spending (spend_credits_on)
|
|
8
|
+
# - Human-friendly API (give_credits, credits, credit_history)
|
|
9
|
+
# - Fulfillment tracking for subscriptions and credit packs
|
|
10
|
+
# - Usage-credits specific callbacks (credits_added, credits_deducted, etc.)
|
|
14
11
|
|
|
12
|
+
class Wallet < Wallets::WalletBase
|
|
15
13
|
# =========================================
|
|
16
|
-
#
|
|
14
|
+
# Embeddability Configuration
|
|
17
15
|
# =========================================
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
self.embedded_table_name = "usage_credits_wallets"
|
|
18
|
+
self.config_provider = -> { UsageCredits.configuration }
|
|
19
|
+
self.callbacks_module = UsageCredits::Callbacks
|
|
20
|
+
self.transaction_class_name = "UsageCredits::Transaction"
|
|
21
|
+
self.allocation_class_name = "UsageCredits::Allocation"
|
|
22
|
+
self.transfer_class_name = "UsageCredits::Transfer"
|
|
23
|
+
self.additional_transaction_attribute_names = %i[fulfillment].freeze
|
|
24
|
+
|
|
25
|
+
# Map base wallet events to usage_credits-specific event names
|
|
26
|
+
self.callback_event_map = {
|
|
27
|
+
credited: :credits_added,
|
|
28
|
+
debited: :credits_deducted,
|
|
29
|
+
insufficient: :insufficient_credits,
|
|
30
|
+
low_balance: :low_balance_reached,
|
|
31
|
+
depleted: :balance_depleted,
|
|
32
|
+
transfer_completed: nil
|
|
33
|
+
}.freeze
|
|
27
34
|
|
|
28
35
|
# =========================================
|
|
29
|
-
#
|
|
36
|
+
# Re-declare Associations with Correct Classes
|
|
30
37
|
# =========================================
|
|
31
38
|
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
# Override parent associations to use UsageCredits classes
|
|
40
|
+
has_many :transactions,
|
|
41
|
+
class_name: "UsageCredits::Transaction",
|
|
42
|
+
inverse_of: :wallet,
|
|
43
|
+
dependent: :destroy
|
|
44
|
+
has_many :outgoing_transfers,
|
|
45
|
+
class_name: "UsageCredits::Transfer",
|
|
46
|
+
foreign_key: :from_wallet_id,
|
|
47
|
+
dependent: :destroy,
|
|
48
|
+
inverse_of: :from_wallet
|
|
49
|
+
has_many :incoming_transfers,
|
|
50
|
+
class_name: "UsageCredits::Transfer",
|
|
51
|
+
foreign_key: :to_wallet_id,
|
|
52
|
+
dependent: :destroy,
|
|
53
|
+
inverse_of: :to_wallet
|
|
54
|
+
|
|
55
|
+
# UsageCredits-specific associations
|
|
56
|
+
has_many :fulfillments, class_name: "UsageCredits::Fulfillment", dependent: :destroy
|
|
40
57
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@indifferent_metadata = nil # Clear cache
|
|
44
|
-
super(hash.is_a?(Hash) ? hash.to_h : {})
|
|
45
|
-
end
|
|
58
|
+
class << self
|
|
59
|
+
private
|
|
46
60
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
def initial_balance_credit_attributes
|
|
62
|
+
{
|
|
63
|
+
category: :manual_adjustment,
|
|
64
|
+
metadata: {reason: "initial_balance"}
|
|
65
|
+
}
|
|
66
|
+
end
|
|
51
67
|
end
|
|
52
68
|
|
|
53
69
|
# =========================================
|
|
54
|
-
#
|
|
70
|
+
# Backwards Compatibility API
|
|
55
71
|
# =========================================
|
|
56
72
|
|
|
57
|
-
# Get current credit balance
|
|
58
|
-
#
|
|
59
|
-
# The first naive approach was to compute this as a sum of all non-expired transactions like:
|
|
60
|
-
# transactions.not_expired.sum(:amount)
|
|
61
|
-
# but that fails when we mix expiring and non-expiring credits: https://x.com/rameerez/status/1884246492837302759
|
|
62
|
-
#
|
|
63
|
-
# So we needed to introduce the Allocation model
|
|
73
|
+
# Get current credit balance (alias for balance)
|
|
64
74
|
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
75
|
+
# usage_credits historically floors negative balances to zero even when
|
|
76
|
+
# allow_negative_balance is enabled. Keep that contract for backwards
|
|
77
|
+
# compatibility, even though the shared wallets core can represent unbacked
|
|
78
|
+
# negative debits explicitly.
|
|
67
79
|
def credits
|
|
68
|
-
|
|
69
|
-
transactions
|
|
70
|
-
.where("amount > 0")
|
|
71
|
-
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
|
72
|
-
.sum("amount - (SELECT COALESCE(SUM(amount), 0) FROM usage_credits_allocations WHERE source_transaction_id = usage_credits_transactions.id)")
|
|
73
|
-
.yield_self { |sum| [sum, 0].max }.to_i
|
|
80
|
+
balance
|
|
74
81
|
end
|
|
75
82
|
|
|
76
|
-
|
|
83
|
+
def current_balance
|
|
84
|
+
# Wallets::Wallet#balance delegates dynamically to current_balance, so
|
|
85
|
+
# this override is the active public balance implementation (not a dead
|
|
86
|
+
# helper). Refund debits remain explicit ledger debt while the Usage
|
|
87
|
+
# Credits compatibility view stays floored at zero.
|
|
88
|
+
refund_debits = transactions.debits.where(category: "credit_pack_refund")
|
|
89
|
+
[positive_remaining_balance - unbacked_negative_balance(refund_debits), 0].max
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get transaction history (oldest first) - alias for history
|
|
77
93
|
def credit_history
|
|
78
|
-
|
|
94
|
+
history
|
|
79
95
|
end
|
|
80
96
|
|
|
81
97
|
# =========================================
|
|
82
|
-
# Credit Operations
|
|
98
|
+
# Credit Operations (High-Level API)
|
|
83
99
|
# =========================================
|
|
84
100
|
|
|
85
101
|
# Check if wallet has enough credits for an operation
|
|
86
102
|
def has_enough_credits_to?(operation_name, **params)
|
|
87
|
-
operation =
|
|
88
|
-
|
|
89
|
-
# Then check if we actually have enough credits
|
|
103
|
+
operation = find_operation(operation_name)
|
|
90
104
|
credits >= operation.calculate_cost(params)
|
|
91
|
-
rescue InvalidOperation
|
|
92
|
-
raise
|
|
93
|
-
rescue
|
|
105
|
+
rescue InvalidOperation
|
|
106
|
+
raise
|
|
107
|
+
rescue => e
|
|
94
108
|
raise InvalidOperation, "Error checking credits: #{e.message}"
|
|
95
109
|
end
|
|
96
110
|
|
|
97
111
|
# Calculate how many credits an operation would cost
|
|
98
112
|
def estimate_credits_to(operation_name, **params)
|
|
99
|
-
operation =
|
|
100
|
-
|
|
101
|
-
# Then calculate the cost
|
|
113
|
+
operation = find_operation(operation_name)
|
|
102
114
|
operation.calculate_cost(params)
|
|
103
|
-
rescue InvalidOperation
|
|
104
|
-
raise
|
|
105
|
-
rescue
|
|
115
|
+
rescue InvalidOperation
|
|
116
|
+
raise
|
|
117
|
+
rescue => e
|
|
106
118
|
raise InvalidOperation, "Error estimating cost: #{e.message}"
|
|
107
119
|
end
|
|
108
120
|
|
|
@@ -111,74 +123,65 @@ module UsageCredits
|
|
|
111
123
|
# @param params [Hash] Parameters for the operation
|
|
112
124
|
# @yield Optional block that must succeed before credits are deducted
|
|
113
125
|
def spend_credits_on(operation_name, **params)
|
|
114
|
-
operation =
|
|
115
|
-
|
|
126
|
+
operation = find_operation(operation_name)
|
|
116
127
|
cost = operation.calculate_cost(params)
|
|
117
128
|
|
|
118
|
-
# Check if user has enough credits
|
|
119
|
-
unless has_enough_credits_to?(operation_name, **params)
|
|
120
|
-
# Fire insufficient_credits callback before raising
|
|
121
|
-
UsageCredits::Callbacks.dispatch(:insufficient_credits,
|
|
122
|
-
wallet: self,
|
|
123
|
-
amount: cost,
|
|
124
|
-
operation_name: operation_name,
|
|
125
|
-
metadata: {
|
|
126
|
-
available: credits,
|
|
127
|
-
required: cost,
|
|
128
|
-
params: params
|
|
129
|
-
}
|
|
130
|
-
)
|
|
131
|
-
raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})"
|
|
132
|
-
end
|
|
133
|
-
|
|
134
129
|
# Create audit trail
|
|
135
|
-
|
|
136
|
-
audit_data = operation.to_audit_hash(params).deep_stringify_keys
|
|
130
|
+
audit_data = operation.to_audit_hash(params, cost: cost).deep_stringify_keys
|
|
137
131
|
deduct_params = {
|
|
138
|
-
metadata: audit_data
|
|
139
|
-
"executed_at" => Time.current,
|
|
140
|
-
"gem_version" => UsageCredits::VERSION
|
|
141
|
-
),
|
|
132
|
+
metadata: audit_data,
|
|
142
133
|
category: :operation_charge
|
|
143
134
|
}
|
|
144
135
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
136
|
+
# One command, like every core command: the affordability check, the
|
|
137
|
+
# protected operation and the debit share the wallet lock, one instant
|
|
138
|
+
# and the core's verdict on this transaction's snapshot. Otherwise a
|
|
139
|
+
# caller that read before spending could check a stale MySQL balance:
|
|
140
|
+
# run the block on credits another request already took, or refuse
|
|
141
|
+
# credits another request just added. Its own savepoint makes it whole
|
|
142
|
+
# either way: a refused debit rolls back the block's writes too, even
|
|
143
|
+
# when the caller rescues and commits.
|
|
144
|
+
with_lock(requires_new: true) do
|
|
145
|
+
mutating_at(Time.current) do
|
|
146
|
+
available = credits
|
|
147
|
+
if cost > available
|
|
148
|
+
UsageCredits::Callbacks.dispatch(:insufficient_credits,
|
|
149
|
+
wallet: self,
|
|
150
|
+
amount: cost,
|
|
151
|
+
operation_name: operation_name,
|
|
152
|
+
metadata: {
|
|
153
|
+
available: available,
|
|
154
|
+
required: cost,
|
|
155
|
+
params: params
|
|
156
|
+
})
|
|
157
|
+
raise InsufficientCredits, "Insufficient credits (#{available} < #{cost})"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
yield if block_given?
|
|
161
|
+
|
|
162
|
+
# Free operations are a supported part of the DSL. There is no valid
|
|
163
|
+
# zero-amount ledger transaction to record, so execute the block and
|
|
164
|
+
# return nil without calling the strictly-positive debit primitive.
|
|
165
|
+
cost.zero? ? nil : deduct_credits(cost, **deduct_params)
|
|
153
166
|
end
|
|
154
|
-
else
|
|
155
|
-
deduct_credits(cost, **deduct_params)
|
|
156
167
|
end
|
|
157
|
-
rescue StandardError => e
|
|
158
|
-
raise e
|
|
159
168
|
end
|
|
160
169
|
|
|
161
170
|
# Give credits to the wallet with optional reason and expiration date
|
|
162
171
|
# @param amount [Integer] Number of credits to give
|
|
163
|
-
# @param reason [String, nil] Optional reason for giving credits
|
|
172
|
+
# @param reason [String, nil] Optional reason for giving credits
|
|
164
173
|
# @param expires_at [DateTime, nil] Optional expiration date for the credits
|
|
165
174
|
def give_credits(amount, reason: nil, expires_at: nil)
|
|
166
|
-
raise ArgumentError, "Amount is required" if amount.nil?
|
|
167
|
-
raise ArgumentError, "Cannot give negative credits" if amount.to_i.negative?
|
|
168
|
-
raise ArgumentError, "Credit amount must be a whole number" unless amount == amount.to_i
|
|
169
|
-
raise ArgumentError, "Expiration date must be a valid datetime" if expires_at && !expires_at.respond_to?(:to_datetime)
|
|
170
|
-
raise ArgumentError, "Expiration date must be in the future" if expires_at && expires_at <= Time.current
|
|
171
|
-
|
|
172
175
|
category = case reason&.to_s
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
when "signup" then :signup_bonus
|
|
177
|
+
when "referral" then :referral_bonus
|
|
178
|
+
when /bonus/i then :bonus
|
|
179
|
+
else :manual_adjustment
|
|
180
|
+
end
|
|
178
181
|
|
|
179
182
|
add_credits(
|
|
180
|
-
amount
|
|
181
|
-
metadata: {
|
|
183
|
+
amount,
|
|
184
|
+
metadata: {reason: reason},
|
|
182
185
|
category: category,
|
|
183
186
|
expires_at: expires_at
|
|
184
187
|
)
|
|
@@ -188,227 +191,136 @@ module UsageCredits
|
|
|
188
191
|
# Credit Management (Internal API)
|
|
189
192
|
# =========================================
|
|
190
193
|
|
|
191
|
-
# Add credits to the wallet (
|
|
194
|
+
# Add credits to the wallet (wraps parent's credit method)
|
|
195
|
+
# Maintains backwards compatibility with fulfillment parameter
|
|
192
196
|
def add_credits(amount, metadata: {}, category: :credit_added, expires_at: nil, fulfillment: nil)
|
|
193
|
-
|
|
194
|
-
amount
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
amount: amount,
|
|
201
|
-
category: category,
|
|
202
|
-
expires_at: expires_at,
|
|
203
|
-
metadata: metadata,
|
|
204
|
-
fulfillment: fulfillment
|
|
205
|
-
)
|
|
206
|
-
|
|
207
|
-
# Sync the wallet's `balance` column
|
|
208
|
-
self.balance = credits
|
|
209
|
-
save!
|
|
210
|
-
|
|
211
|
-
# Store balance information in transaction metadata for audit trail.
|
|
212
|
-
# Note: This update! is in the same DB transaction as the create! above (via with_lock),
|
|
213
|
-
# so if this fails, the entire transaction rolls back - no orphaned records possible.
|
|
214
|
-
# We intentionally overwrite any user-supplied balance_before/balance_after keys
|
|
215
|
-
# to ensure system-set values are authoritative.
|
|
216
|
-
transaction.update!(metadata: transaction.metadata.merge(
|
|
217
|
-
balance_before: previous_balance,
|
|
218
|
-
balance_after: balance
|
|
219
|
-
))
|
|
220
|
-
|
|
221
|
-
# Dispatch callback with full context
|
|
222
|
-
UsageCredits::Callbacks.dispatch(:credits_added,
|
|
223
|
-
wallet: self,
|
|
224
|
-
amount: amount,
|
|
225
|
-
category: category,
|
|
226
|
-
transaction: transaction,
|
|
227
|
-
previous_balance: previous_balance,
|
|
228
|
-
new_balance: balance,
|
|
229
|
-
metadata: metadata
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
# To finish, let's return the transaction that has been just created so we can reference it in parts of the code
|
|
233
|
-
# Useful, for example, to update the transaction's `fulfillment` reference in the subscription extension
|
|
234
|
-
# after the credits have been awarded and the Fulfillment object has been created, we need to store it
|
|
235
|
-
return transaction
|
|
236
|
-
end
|
|
197
|
+
credit(
|
|
198
|
+
amount,
|
|
199
|
+
metadata: metadata,
|
|
200
|
+
category: category,
|
|
201
|
+
expires_at: expires_at,
|
|
202
|
+
fulfillment: fulfillment
|
|
203
|
+
)
|
|
237
204
|
end
|
|
238
205
|
|
|
239
|
-
# Remove credits from the wallet (
|
|
240
|
-
#
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
# That's fine if usage scale is moderate. We're already indexing this.
|
|
247
|
-
# If performance becomes a concern, we need to create a separate model to store the partial allocations efficiently.
|
|
248
|
-
def deduct_credits(amount, metadata: {}, category: :credit_deducted)
|
|
249
|
-
with_lock do
|
|
250
|
-
amount = amount.to_i
|
|
251
|
-
raise InsufficientCredits, "Cannot deduct a non-positive amount" if amount <= 0
|
|
252
|
-
|
|
253
|
-
# Capture previous balance for low_balance check
|
|
254
|
-
previous_balance = credits
|
|
255
|
-
|
|
256
|
-
# Figure out how many credits are available right now
|
|
257
|
-
available = previous_balance
|
|
258
|
-
if amount > available && !allow_negative_balance?
|
|
259
|
-
raise InsufficientCredits, "Insufficient credits (#{available} < #{amount})"
|
|
260
|
-
end
|
|
206
|
+
# Remove credits from the wallet (wraps parent's debit method)
|
|
207
|
+
# Converts Wallets::InsufficientBalance to InsufficientCredits for backwards compatibility
|
|
208
|
+
def deduct_credits(amount, metadata: {}, category: :credit_deducted, fulfillment: nil)
|
|
209
|
+
debit(amount, metadata: metadata, category: category, fulfillment: fulfillment)
|
|
210
|
+
rescue Wallets::InsufficientBalance => e
|
|
211
|
+
raise InsufficientCredits, in_credits_terms(e.message)
|
|
212
|
+
end
|
|
261
213
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
remaining_to_deduct = amount
|
|
271
|
-
|
|
272
|
-
# 1) Gather all unexpired positives with leftover, order by expire time (soonest first),
|
|
273
|
-
# then fallback to any with no expiry (which should come last).
|
|
274
|
-
positive_txs = transactions
|
|
275
|
-
.where("amount > 0")
|
|
276
|
-
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
|
277
|
-
.order(Arel.sql("COALESCE(expires_at, '9999-12-31 23:59:59'), id ASC"))
|
|
278
|
-
.lock("FOR UPDATE")
|
|
279
|
-
.select(:id, :amount, :expires_at)
|
|
280
|
-
.to_a
|
|
281
|
-
|
|
282
|
-
positive_txs.each do |pt|
|
|
283
|
-
# Calculate leftover amount for this transaction
|
|
284
|
-
allocated = pt.incoming_allocations.sum(:amount)
|
|
285
|
-
leftover = pt.amount - allocated
|
|
286
|
-
next if leftover <= 0
|
|
287
|
-
|
|
288
|
-
allocate_amount = [leftover, remaining_to_deduct].min
|
|
289
|
-
|
|
290
|
-
# Create allocation
|
|
291
|
-
Allocation.create!(
|
|
292
|
-
spend_transaction: spend_tx,
|
|
293
|
-
source_transaction: pt,
|
|
294
|
-
amount: allocate_amount
|
|
295
|
-
)
|
|
296
|
-
|
|
297
|
-
remaining_to_deduct -= allocate_amount
|
|
298
|
-
break if remaining_to_deduct <= 0
|
|
214
|
+
# Shorten the lifetime of credits minted by one fulfillment and reconcile
|
|
215
|
+
# the wallet through the same balance/callback internals as core mutations.
|
|
216
|
+
# This keeps Pay lifecycle code out of Wallets' private implementation and
|
|
217
|
+
# makes immediate cancellation expiry observable through low/depleted
|
|
218
|
+
# callbacks after the surrounding transaction commits.
|
|
219
|
+
def expire_fulfillment_credits!(fulfillment:, expires_at:)
|
|
220
|
+
unless expires_at.respond_to?(:to_datetime)
|
|
221
|
+
raise ArgumentError, "Expiration date must respond to to_datetime"
|
|
299
222
|
end
|
|
300
223
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
# with no “source bucket” to allocate from. That is an edge case the end user's business logic must handle.
|
|
306
|
-
elsif remaining_to_deduct.positive?
|
|
307
|
-
# We shouldn’t get here if InsufficientCredits is raised earlier, but just in case:
|
|
308
|
-
raise InsufficientCredits, "Not enough credit buckets to cover the deduction"
|
|
224
|
+
expiration = begin
|
|
225
|
+
expires_at.to_datetime
|
|
226
|
+
rescue
|
|
227
|
+
raise ArgumentError, "Expiration date must be a valid date or time"
|
|
309
228
|
end
|
|
310
229
|
|
|
311
|
-
#
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
metadata: metadata
|
|
334
|
-
)
|
|
335
|
-
|
|
336
|
-
# Check for low balance threshold crossing
|
|
337
|
-
if !was_low_balance?(previous_balance) && low_balance?
|
|
338
|
-
UsageCredits::Callbacks.dispatch(:low_balance_reached,
|
|
339
|
-
wallet: self,
|
|
340
|
-
threshold: UsageCredits.configuration.low_balance_threshold,
|
|
341
|
-
previous_balance: previous_balance,
|
|
342
|
-
new_balance: balance
|
|
343
|
-
)
|
|
230
|
+
# A command like any other: its own savepoint, and one instant and one
|
|
231
|
+
# snapshot verdict for every read. Outside the core's mutation context,
|
|
232
|
+
# refreshing the cached balance on a stale MySQL snapshot would cache a
|
|
233
|
+
# stale balance and bump `ledger_revision` unnoticed; the next command in
|
|
234
|
+
# the same transaction would then see its own revision and trust the
|
|
235
|
+
# stale snapshot.
|
|
236
|
+
with_lock(requires_new: true) do
|
|
237
|
+
mutating_at(Time.current) do
|
|
238
|
+
previous_balance = balance
|
|
239
|
+
transactions_to_expire = transactions.credits.where(fulfillment: fulfillment)
|
|
240
|
+
expiry = transactions_to_expire.klass.arel_table[:expires_at]
|
|
241
|
+
updated_count = transactions_to_expire
|
|
242
|
+
.where(expiry.eq(nil).or(expiry.gt(expiration)))
|
|
243
|
+
.update_all(expires_at: expiration, updated_at: evaluation_time)
|
|
244
|
+
|
|
245
|
+
if updated_count.positive?
|
|
246
|
+
new_balance = refresh_cached_balance!
|
|
247
|
+
dispatch_balance_threshold_callbacks!(previous_balance, new_balance)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
updated_count
|
|
251
|
+
end
|
|
344
252
|
end
|
|
253
|
+
end
|
|
345
254
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
255
|
+
# Keep the inherited wallet primitive inside usage_credits' public error
|
|
256
|
+
# hierarchy. Both transfer entry points share this implementation and the
|
|
257
|
+
# complete wallets transfer surface, including expiration overrides.
|
|
258
|
+
def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expiration_policy: nil, expires_at: nil)
|
|
259
|
+
super
|
|
260
|
+
rescue Wallets::InvalidTransfer => e
|
|
261
|
+
raise InvalidTransfer, e.message
|
|
262
|
+
rescue Wallets::InsufficientBalance => e
|
|
263
|
+
raise InsufficientCredits, in_credits_terms(e.message)
|
|
264
|
+
rescue Wallets::Error => e
|
|
265
|
+
raise UsageCredits::Error, e.message
|
|
266
|
+
end
|
|
354
267
|
|
|
355
|
-
|
|
268
|
+
alias_method :transfer_credits_to, :transfer_to
|
|
269
|
+
|
|
270
|
+
# The Pay charge extension's refund webhook handler calls this; it is not a
|
|
271
|
+
# general-purpose API. Claws back a refund: the part of `total_credits`, what the refunds of
|
|
272
|
+
# one purchase now add up to, that the ledger has not taken yet. The
|
|
273
|
+
# block receives this wallet's refund debits and returns what earlier
|
|
274
|
+
# refunds of the same purchase already took (nothing, without a block).
|
|
275
|
+
# The lookup and the debit are one command, under the wallet lock, at one
|
|
276
|
+
# instant and with one verdict on this transaction's snapshot, so the
|
|
277
|
+
# refund debits are read as they are now. Outside the command the lookup
|
|
278
|
+
# could miss a refund another connection committed after this
|
|
279
|
+
# transaction's first read (Pay's commit callbacks run inside a
|
|
280
|
+
# `transaction(joinable: false)`), and under MySQL's repeatable read the
|
|
281
|
+
# same credits would be clawed back twice. The debit may be unbacked: a
|
|
282
|
+
# refund is owed even after the purchased credits were spent, as ledger
|
|
283
|
+
# debt that later credits repay while `credits` stays floored at zero.
|
|
284
|
+
# Returns the refund transaction, or nil when nothing is outstanding.
|
|
285
|
+
def deduct_refunded_credits(total_credits, metadata:, fulfillment:)
|
|
286
|
+
with_lock(requires_new: true) do
|
|
287
|
+
mutating_at(Time.current) do
|
|
288
|
+
refunds = current_read(transactions.debits.where(category: "credit_pack_refund"), current_reads?)
|
|
289
|
+
already_refunded = block_given? ? yield(refunds) : 0
|
|
290
|
+
outstanding = total_credits - already_refunded
|
|
291
|
+
next if outstanding <= 0
|
|
292
|
+
|
|
293
|
+
apply_debit(
|
|
294
|
+
outstanding,
|
|
295
|
+
metadata: metadata.merge(incremental_credits: outstanding),
|
|
296
|
+
category: :credit_pack_refund,
|
|
297
|
+
transfer: nil,
|
|
298
|
+
extra_attributes: {fulfillment: fulfillment},
|
|
299
|
+
allow_unbacked: true
|
|
300
|
+
)
|
|
301
|
+
end
|
|
356
302
|
end
|
|
357
303
|
end
|
|
358
304
|
|
|
359
|
-
|
|
360
305
|
private
|
|
361
306
|
|
|
362
|
-
#
|
|
363
|
-
#
|
|
364
|
-
#
|
|
365
|
-
def
|
|
366
|
-
|
|
367
|
-
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
368
|
-
elsif read_attribute(:metadata).nil?
|
|
369
|
-
write_attribute(:metadata, {})
|
|
370
|
-
end
|
|
307
|
+
# The wallets core refuses in its own words ("Insufficient balance (5 <
|
|
308
|
+
# 10)"); a credits wallet refuses in the words its own `InsufficientCredits`
|
|
309
|
+
# already uses, whichever command raised it.
|
|
310
|
+
def in_credits_terms(message)
|
|
311
|
+
message.sub(/\AInsufficient balance\b/, "Insufficient credits")
|
|
371
312
|
end
|
|
372
313
|
|
|
373
314
|
# =========================================
|
|
374
315
|
# Helper Methods
|
|
375
316
|
# =========================================
|
|
376
317
|
|
|
377
|
-
# Find an operation
|
|
378
|
-
#
|
|
379
|
-
|
|
380
|
-
# @return [Operation] The validated operation
|
|
381
|
-
# @raise [InvalidOperation] If operation not found or validation fails
|
|
382
|
-
def find_and_validate_operation(name, params)
|
|
318
|
+
# Find an operation. `Operation#calculate_cost` owns parameter validation,
|
|
319
|
+
# keeping validation and user-supplied cost code single-evaluation.
|
|
320
|
+
def find_operation(name)
|
|
383
321
|
operation = UsageCredits.operations[name.to_sym]
|
|
384
322
|
raise InvalidOperation, "Operation not found: #{name}" unless operation
|
|
385
|
-
operation.validate!(params)
|
|
386
323
|
operation
|
|
387
324
|
end
|
|
388
|
-
|
|
389
|
-
def insufficient_credits?(amount)
|
|
390
|
-
!allow_negative_balance? && amount > credits
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
def allow_negative_balance?
|
|
394
|
-
UsageCredits.configuration.allow_negative_balance
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
# =========================================
|
|
398
|
-
# Balance Threshold Helpers
|
|
399
|
-
# =========================================
|
|
400
|
-
|
|
401
|
-
def low_balance?
|
|
402
|
-
threshold = UsageCredits.configuration.low_balance_threshold
|
|
403
|
-
return false if threshold.nil? || threshold.negative?
|
|
404
|
-
credits <= threshold
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
def was_low_balance?(previous_balance)
|
|
408
|
-
threshold = UsageCredits.configuration.low_balance_threshold
|
|
409
|
-
return false if threshold.nil? || threshold.negative?
|
|
410
|
-
previous_balance <= threshold
|
|
411
|
-
end
|
|
412
325
|
end
|
|
413
|
-
|
|
414
326
|
end
|
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
# Railtie for Rails integration
|
|
5
|
-
class Railtie < Rails::Railtie
|
|
6
|
-
railtie_name :usage_credits
|
|
7
|
-
|
|
8
|
-
# Set up action view helpers if needed
|
|
9
|
-
initializer "usage_credits.action_view" do
|
|
10
|
-
ActiveSupport.on_load :action_view do
|
|
11
|
-
require "usage_credits/helpers/credits_helper"
|
|
12
|
-
include UsageCredits::CreditsHelper
|
|
13
|
-
end
|
|
14
|
-
end
|
|
3
|
+
require "usage_credits" unless defined?(UsageCredits::Engine)
|
|
15
4
|
|
|
16
|
-
|
|
5
|
+
module UsageCredits
|
|
6
|
+
# Compatibility constant for applications that require this historical path.
|
|
7
|
+
Railtie = Engine unless const_defined?(:Railtie, false)
|
|
17
8
|
end
|