usage_credits 0.5.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -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 +219 -314
- 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
|
@@ -6,11 +6,19 @@ module UsageCredits
|
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
8
|
included do
|
|
9
|
+
# Filter to the default "credits" asset_code for backwards compatibility
|
|
9
10
|
has_one :credit_wallet,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
-> { where(asset_code: UsageCredits::DEFAULT_ASSET_CODE) },
|
|
12
|
+
class_name: "UsageCredits::Wallet",
|
|
13
|
+
as: :owner,
|
|
14
|
+
dependent: :destroy
|
|
15
|
+
|
|
16
|
+
# NOTE on the alias dance below: `credits_wallet` and `wallet` are aliased
|
|
17
|
+
# to the raw has_one reader *before* `credit_wallet` is redefined to
|
|
18
|
+
# auto-create missing wallets (see `define_method(:credit_wallet)` further
|
|
19
|
+
# down). So `user.credit_wallet` auto-creates, while `user.wallet` /
|
|
20
|
+
# `user.credits_wallet` just read. This asymmetry is the pre-1.0 contract,
|
|
21
|
+
# kept as-is for backwards compatibility.
|
|
14
22
|
alias_method :credits_wallet, :credit_wallet
|
|
15
23
|
alias_method :wallet, :credit_wallet
|
|
16
24
|
|
|
@@ -18,13 +26,13 @@ module UsageCredits
|
|
|
18
26
|
|
|
19
27
|
# More intuitive delegations
|
|
20
28
|
delegate :credits,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
:credit_history,
|
|
30
|
+
:has_enough_credits_to?,
|
|
31
|
+
:estimate_credits_to,
|
|
32
|
+
:spend_credits_on,
|
|
33
|
+
:give_credits,
|
|
34
|
+
to: :ensure_credit_wallet,
|
|
35
|
+
allow_nil: false # Never return nil for these methods
|
|
28
36
|
|
|
29
37
|
# Fix recursion by properly aliasing the original method
|
|
30
38
|
alias_method :original_credit_wallet, :credit_wallet
|
|
@@ -44,7 +52,6 @@ module UsageCredits
|
|
|
44
52
|
.map { |f| UsageCredits.find_subscription_plan_by_processor_id(f.metadata["plan"]) }
|
|
45
53
|
.compact
|
|
46
54
|
end
|
|
47
|
-
|
|
48
55
|
end
|
|
49
56
|
|
|
50
57
|
# Class methods added to the model
|
|
@@ -52,15 +59,17 @@ module UsageCredits
|
|
|
52
59
|
def has_credits(**options)
|
|
53
60
|
include UsageCredits::HasWallet unless included_modules.include?(UsageCredits::HasWallet)
|
|
54
61
|
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@credit_options[:auto_create] = true if @credit_options[:auto_create].nil?
|
|
62
|
+
# Each class owns an immutable options snapshot. Subclasses inherit it
|
|
63
|
+
# until they explicitly call `has_credits`, without sharing a mutable
|
|
64
|
+
# hash with their parent.
|
|
65
|
+
@credit_options = {auto_create: true}.merge(options.symbolize_keys).freeze
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
def credit_options
|
|
63
|
-
@credit_options
|
|
69
|
+
return @credit_options if instance_variable_defined?(:@credit_options)
|
|
70
|
+
return superclass.credit_options if superclass.respond_to?(:credit_options)
|
|
71
|
+
|
|
72
|
+
{auto_create: true}
|
|
64
73
|
end
|
|
65
74
|
end
|
|
66
75
|
|
|
@@ -75,16 +84,30 @@ module UsageCredits
|
|
|
75
84
|
end
|
|
76
85
|
|
|
77
86
|
def ensure_credit_wallet
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
credit_wallet_association = association(:credit_wallet)
|
|
88
|
+
cached_wallet = credit_wallet_association.target if credit_wallet_association.loaded?
|
|
89
|
+
return cached_wallet if cached_wallet&.persisted?
|
|
90
|
+
|
|
91
|
+
# With auto-creation disabled, retain the ordinary has_one reader and
|
|
92
|
+
# refresh a cached miss so a concurrently-created wallet is still seen.
|
|
93
|
+
# With it enabled, create_for_owner! is the one lookup/creation primitive:
|
|
94
|
+
# it returns an existing row or creates it race-safely. This avoids
|
|
95
|
+
# querying once through the association and then repeating the same
|
|
96
|
+
# owner/asset lookup in create_for_owner! on a cold cache.
|
|
97
|
+
unless should_create_wallet?
|
|
98
|
+
return original_credit_wallet unless credit_wallet_association.loaded?
|
|
99
|
+
return credit_wallet_association.reload.target
|
|
87
100
|
end
|
|
101
|
+
raise "Cannot create wallet for unsaved owner" unless persisted?
|
|
102
|
+
|
|
103
|
+
wallet = UsageCredits::Wallet.create_for_owner!(
|
|
104
|
+
owner: self,
|
|
105
|
+
asset_code: UsageCredits::DEFAULT_ASSET_CODE,
|
|
106
|
+
initial_balance: credit_options[:initial_balance]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self.credit_wallet = wallet
|
|
110
|
+
wallet
|
|
88
111
|
end
|
|
89
112
|
|
|
90
113
|
def create_credit_wallet
|
|
@@ -11,7 +11,7 @@ module UsageCredits
|
|
|
11
11
|
|
|
12
12
|
included do
|
|
13
13
|
after_initialize :init_metadata
|
|
14
|
-
after_commit :fulfill_credit_pack
|
|
14
|
+
after_commit :fulfill_credit_pack!, on: [:create, :update]
|
|
15
15
|
after_commit :handle_refund!, on: :update, if: :refund_needed?
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -40,9 +40,9 @@ module UsageCredits
|
|
|
40
40
|
return amount_captured == amount.to_i && amount.to_i.positive?
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
43
|
+
# Pay's non-Stripe adapters persist Pay::Charge rows only after their
|
|
44
|
+
# processor-specific success event (for example, Paddle Billing ignores
|
|
45
|
+
# every transaction whose status is not "completed").
|
|
46
46
|
true
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -95,6 +95,45 @@ module UsageCredits
|
|
|
95
95
|
metadata["pack_name"]
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
+
# Checkout metadata is the immutable commercial snapshot: it records what
|
|
99
|
+
# the customer bought at payment time. Runtime configuration may be edited
|
|
100
|
+
# or the pack may be removed before a delayed webhook/refund arrives, so it
|
|
101
|
+
# must only be a legacy fallback, never the source of truth for quantities.
|
|
102
|
+
def credit_pack_snapshot
|
|
103
|
+
pack_name = pack_identifier.to_s
|
|
104
|
+
pack = UsageCredits.find_pack(pack_name.to_sym)
|
|
105
|
+
|
|
106
|
+
credits = strict_metadata_integer("credits", fallback: pack&.credits)
|
|
107
|
+
bonus_credits = strict_metadata_integer("bonus_credits", fallback: pack&.bonus_credits || 0)
|
|
108
|
+
return if credits.nil? || bonus_credits.nil? || !credits.positive? || bonus_credits.negative?
|
|
109
|
+
|
|
110
|
+
snapshot = {
|
|
111
|
+
pack_name: pack_name,
|
|
112
|
+
credits: credits,
|
|
113
|
+
bonus_credits: bonus_credits,
|
|
114
|
+
total_credits: credits + bonus_credits,
|
|
115
|
+
price_cents: strict_metadata_integer("price_cents", fallback: pack&.price_cents),
|
|
116
|
+
price_currency: (metadata["price_currency"].presence || pack&.price_currency || currency).to_s.upcase
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if pack && (pack.credits != credits || pack.bonus_credits != bonus_credits)
|
|
120
|
+
Rails.logger.warn "Credit pack #{pack_name} changed after charge #{id}; honoring checkout snapshot (#{credits} + #{bonus_credits} credits)"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
snapshot
|
|
124
|
+
rescue ArgumentError => e
|
|
125
|
+
Rails.logger.error "Invalid credit pack metadata for charge #{id}: #{e.message}"
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def strict_metadata_integer(key, fallback: nil)
|
|
130
|
+
value = metadata[key]
|
|
131
|
+
value = fallback if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
132
|
+
return nil if value.nil?
|
|
133
|
+
|
|
134
|
+
Wallets::WholeNumber.parse(value, name: key, allow_string: true)
|
|
135
|
+
end
|
|
136
|
+
|
|
98
137
|
def credits_already_fulfilled?
|
|
99
138
|
# First check if there's a fulfillment record for this charge
|
|
100
139
|
return true if UsageCredits::Fulfillment.exists?(source: self)
|
|
@@ -106,12 +145,12 @@ module UsageCredits
|
|
|
106
145
|
return false unless transactions.present?
|
|
107
146
|
|
|
108
147
|
begin
|
|
109
|
-
adapter =
|
|
148
|
+
adapter = transactions.connection.adapter_name.downcase
|
|
110
149
|
if adapter.include?("postgres")
|
|
111
150
|
# PostgreSQL supports the @> JSON containment operator.
|
|
112
|
-
transactions.exists?([
|
|
113
|
-
elsif adapter.
|
|
114
|
-
# MySQL: JSON_EXTRACT returns JSON values, use CAST for proper comparison
|
|
151
|
+
transactions.exists?(["metadata @> ?", {purchase_charge_id: id, credits_fulfilled: true}.to_json])
|
|
152
|
+
elsif adapter.match?(/mysql|trilogy/)
|
|
153
|
+
# MySQL (Trilogy is MySQL under another name): JSON_EXTRACT returns JSON values, use CAST for proper comparison
|
|
115
154
|
transactions.exists?([
|
|
116
155
|
"JSON_EXTRACT(metadata, '$.purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_fulfilled') = CAST('true' AS JSON)",
|
|
117
156
|
id
|
|
@@ -128,7 +167,11 @@ module UsageCredits
|
|
|
128
167
|
if tx.metadata.is_a?(Hash)
|
|
129
168
|
tx.metadata
|
|
130
169
|
else
|
|
131
|
-
|
|
170
|
+
begin
|
|
171
|
+
JSON.parse(tx.metadata)
|
|
172
|
+
rescue
|
|
173
|
+
{}
|
|
174
|
+
end
|
|
132
175
|
end
|
|
133
176
|
data["purchase_charge_id"].to_i == id.to_i && data["credits_fulfilled"].to_s == "true"
|
|
134
177
|
end
|
|
@@ -137,7 +180,7 @@ module UsageCredits
|
|
|
137
180
|
|
|
138
181
|
def fulfill_credit_pack!
|
|
139
182
|
return unless is_credit_pack_purchase?
|
|
140
|
-
return unless pack_identifier
|
|
183
|
+
return unless pack_identifier.present?
|
|
141
184
|
return unless has_valid_wallet?
|
|
142
185
|
return unless succeeded?
|
|
143
186
|
return if refunded?
|
|
@@ -145,111 +188,116 @@ module UsageCredits
|
|
|
145
188
|
|
|
146
189
|
Rails.logger.info "Starting to process charge #{id} to fulfill credits"
|
|
147
190
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
unless pack
|
|
152
|
-
Rails.logger.error "Credit pack not found: #{pack_name} for charge #{id}"
|
|
191
|
+
snapshot = credit_pack_snapshot
|
|
192
|
+
unless snapshot
|
|
193
|
+
Rails.logger.error "Credit pack snapshot is missing or invalid for charge #{id}"
|
|
153
194
|
return
|
|
154
195
|
end
|
|
155
196
|
|
|
156
|
-
# Validate that the pack details match if they're provided in metadata
|
|
157
|
-
if metadata["credits"].present?
|
|
158
|
-
expected_credits = metadata["credits"].to_i
|
|
159
|
-
if expected_credits != pack.credits
|
|
160
|
-
Rails.logger.error "Credit pack mismatch: expected #{expected_credits} credits but pack #{pack_name} provides #{pack.credits}"
|
|
161
|
-
return
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
|
|
165
197
|
begin
|
|
198
|
+
wallet = credit_wallet
|
|
166
199
|
credit_transaction = nil
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
200
|
+
fulfillment = nil
|
|
201
|
+
fulfilled = false
|
|
202
|
+
|
|
203
|
+
# The wallet lock serializes duplicate webhook deliveries for the same
|
|
204
|
+
# owner. Re-check idempotency inside that lock; the database's unique
|
|
205
|
+
# source index is the final guard against duplicate fulfillment rows.
|
|
206
|
+
wallet.with_lock do
|
|
207
|
+
next if UsageCredits::Fulfillment.exists?(source: self)
|
|
208
|
+
|
|
209
|
+
fulfilled_at = Time.current
|
|
210
|
+
fulfillment = Fulfillment.create!(
|
|
211
|
+
wallet: wallet,
|
|
212
|
+
source: self,
|
|
213
|
+
fulfillment_type: "credit_pack",
|
|
214
|
+
credits_last_fulfillment: snapshot.fetch(:total_credits),
|
|
215
|
+
last_fulfilled_at: fulfilled_at,
|
|
216
|
+
next_fulfillment_at: nil,
|
|
217
|
+
metadata: snapshot.merge(
|
|
176
218
|
purchase_charge_id: id,
|
|
177
|
-
purchased_at: created_at
|
|
178
|
-
|
|
179
|
-
fulfilled_at: Time.current,
|
|
180
|
-
**pack.base_metadata
|
|
181
|
-
}
|
|
219
|
+
purchased_at: created_at
|
|
220
|
+
)
|
|
182
221
|
)
|
|
183
222
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
source: self, # the Pay::Charge
|
|
189
|
-
fulfillment_type: "credit_pack",
|
|
190
|
-
credits_last_fulfillment: pack.total_credits,
|
|
191
|
-
last_fulfilled_at: Time.current,
|
|
192
|
-
next_fulfillment_at: nil, # so it doesn't get re-processed
|
|
223
|
+
credit_transaction = wallet.add_credits(
|
|
224
|
+
snapshot.fetch(:total_credits),
|
|
225
|
+
category: "credit_pack_purchase",
|
|
226
|
+
fulfillment: fulfillment,
|
|
193
227
|
metadata: {
|
|
194
228
|
purchase_charge_id: id,
|
|
195
229
|
purchased_at: created_at,
|
|
196
|
-
|
|
230
|
+
credits_fulfilled: true,
|
|
231
|
+
fulfilled_at: fulfilled_at,
|
|
232
|
+
**snapshot
|
|
197
233
|
}
|
|
198
234
|
)
|
|
235
|
+
fulfilled = true
|
|
199
236
|
end
|
|
200
237
|
|
|
238
|
+
return unless fulfilled
|
|
239
|
+
|
|
201
240
|
# Dispatch credit_pack_purchased callback after successful fulfillment
|
|
202
241
|
# Note: credits_added callback was already fired by add_credits
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
242
|
+
ActiveRecord.after_all_transactions_commit do
|
|
243
|
+
UsageCredits::Callbacks.dispatch(:credit_pack_purchased,
|
|
244
|
+
wallet: wallet,
|
|
245
|
+
amount: snapshot.fetch(:total_credits),
|
|
246
|
+
transaction: credit_transaction,
|
|
247
|
+
metadata: {
|
|
248
|
+
credit_pack_name: snapshot.fetch(:pack_name).to_sym,
|
|
249
|
+
credit_pack: UsageCredits.find_pack(snapshot.fetch(:pack_name).to_sym),
|
|
250
|
+
pay_charge: self,
|
|
251
|
+
fulfillment: fulfillment,
|
|
252
|
+
price_cents: snapshot[:price_cents]
|
|
253
|
+
})
|
|
254
|
+
end
|
|
214
255
|
|
|
215
|
-
Rails.logger.info "Successfully fulfilled credit pack #{pack_name} for charge #{id}"
|
|
216
|
-
rescue
|
|
217
|
-
|
|
256
|
+
Rails.logger.info "Successfully fulfilled credit pack #{snapshot.fetch(:pack_name)} for charge #{id}"
|
|
257
|
+
rescue ActiveRecord::RecordNotUnique
|
|
258
|
+
# A concurrent delivery committed the unique source row first. Treat
|
|
259
|
+
# that committed fulfillment as the idempotent winner.
|
|
260
|
+
return if UsageCredits::Fulfillment.exists?(source: self)
|
|
261
|
+
raise
|
|
262
|
+
rescue => e
|
|
263
|
+
Rails.logger.error "Failed to fulfill credit pack #{pack_identifier} for charge #{id}: #{e.message}"
|
|
218
264
|
raise
|
|
219
265
|
end
|
|
220
266
|
end
|
|
221
267
|
|
|
222
268
|
# Returns the total credits already refunded for this charge
|
|
223
269
|
# Note: NOT memoized because refunds can happen incrementally within the same request
|
|
224
|
-
|
|
225
|
-
|
|
270
|
+
# What earlier refunds of this charge already took, summed from
|
|
271
|
+
# `transactions`: the wallet's refund debits, which the wallet's refund
|
|
272
|
+
# command passes in read as the ledger is now.
|
|
273
|
+
def credits_previously_refunded(transactions = credit_wallet&.transactions&.where(category: "credit_pack_refund"))
|
|
226
274
|
return 0 unless transactions.present?
|
|
227
275
|
|
|
228
276
|
# Try database-level filtering first (more efficient)
|
|
229
277
|
begin
|
|
230
|
-
adapter =
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
278
|
+
adapter = transactions.connection.adapter_name.downcase
|
|
279
|
+
filtered =
|
|
280
|
+
if adapter.include?("postgres")
|
|
281
|
+
# PostgreSQL supports the @> JSON containment operator
|
|
282
|
+
transactions.where(
|
|
283
|
+
"metadata @> ?",
|
|
284
|
+
{refunded_purchase_charge_id: id, credits_refunded: true}.to_json
|
|
285
|
+
)
|
|
286
|
+
elsif adapter.match?(/mysql|trilogy/)
|
|
287
|
+
# MySQL (Trilogy is MySQL under another name): JSON_EXTRACT returns JSON values, use CAST for proper comparison
|
|
288
|
+
transactions.where(
|
|
289
|
+
"JSON_EXTRACT(metadata, '$.refunded_purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_refunded') = CAST('true' AS JSON)",
|
|
290
|
+
id
|
|
291
|
+
)
|
|
292
|
+
else
|
|
293
|
+
# SQLite: json_extract returns SQL values (true becomes 1)
|
|
294
|
+
transactions.where(
|
|
295
|
+
"json_extract(metadata, '$.refunded_purchase_charge_id') = ? AND json_extract(metadata, '$.credits_refunded') = ?",
|
|
296
|
+
id, 1
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
return filtered.sum { |tx| -tx.amount }
|
|
253
301
|
rescue ActiveRecord::StatementInvalid => e
|
|
254
302
|
Rails.logger.warn "JSON query failed, falling back to Ruby filtering: #{e.message}"
|
|
255
303
|
end
|
|
@@ -257,7 +305,11 @@ module UsageCredits
|
|
|
257
305
|
# Fallback: filter in Ruby (for databases without JSON support)
|
|
258
306
|
# Sum in a single pass to avoid multiple iterations
|
|
259
307
|
transactions.sum do |tx|
|
|
260
|
-
data = tx.metadata.is_a?(Hash) ? tx.metadata :
|
|
308
|
+
data = tx.metadata.is_a?(Hash) ? tx.metadata : begin
|
|
309
|
+
JSON.parse(tx.metadata)
|
|
310
|
+
rescue
|
|
311
|
+
{}
|
|
312
|
+
end
|
|
261
313
|
if data["refunded_purchase_charge_id"].to_i == id.to_i && data["credits_refunded"].to_s == "true"
|
|
262
314
|
-tx.amount
|
|
263
315
|
else
|
|
@@ -266,30 +318,29 @@ module UsageCredits
|
|
|
266
318
|
end
|
|
267
319
|
end
|
|
268
320
|
|
|
269
|
-
def credits_already_refunded?
|
|
270
|
-
# Check if any refund was already processed for this charge
|
|
271
|
-
credits_previously_refunded > 0
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def fully_refunded?
|
|
275
|
-
# Check if a full refund (100%) has already been processed
|
|
276
|
-
pack = UsageCredits.find_pack(pack_identifier&.to_sym)
|
|
277
|
-
return false unless pack
|
|
278
|
-
credits_previously_refunded >= pack.total_credits
|
|
279
|
-
end
|
|
280
|
-
|
|
281
321
|
def handle_refund!
|
|
282
322
|
# Guard clauses for required data and state
|
|
283
323
|
return unless refunded?
|
|
284
|
-
return unless pack_identifier
|
|
324
|
+
return unless pack_identifier.present?
|
|
285
325
|
return unless has_valid_wallet?
|
|
286
326
|
return unless amount.is_a?(Numeric) && amount.positive?
|
|
287
327
|
|
|
288
|
-
|
|
289
|
-
|
|
328
|
+
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
|
329
|
+
# Processor metadata proves what a charge was intended to buy, not that
|
|
330
|
+
# the corresponding credits were ever issued. A refund can arrive for a
|
|
331
|
+
# charge whose fulfillment failed (or whose create webhook was never
|
|
332
|
+
# delivered); clawing that metadata snapshot back would manufacture
|
|
333
|
+
# credit debt for value the customer never received. Keep the legacy
|
|
334
|
+
# transaction lookup for pre-1.0 purchases that predate Fulfillment rows.
|
|
335
|
+
unless fulfillment || credits_already_fulfilled?
|
|
336
|
+
Rails.logger.error "Cannot refund credits for charge #{id}: no completed credit fulfillment exists"
|
|
337
|
+
return
|
|
338
|
+
end
|
|
290
339
|
|
|
291
|
-
|
|
292
|
-
|
|
340
|
+
snapshot = fulfillment&.metadata&.symbolize_keys || credit_pack_snapshot
|
|
341
|
+
total_purchased_credits = fulfillment&.credits_last_fulfillment || snapshot&.fetch(:total_credits, nil)
|
|
342
|
+
unless snapshot && total_purchased_credits&.positive?
|
|
343
|
+
Rails.logger.error "Original credit pack fulfillment is missing for refund on charge #{id}"
|
|
293
344
|
return
|
|
294
345
|
end
|
|
295
346
|
|
|
@@ -299,51 +350,45 @@ module UsageCredits
|
|
|
299
350
|
return
|
|
300
351
|
end
|
|
301
352
|
|
|
302
|
-
# Calculate total credits that SHOULD be refunded based on current refund amount
|
|
303
|
-
refund_ratio = amount_refunded.to_f / amount.to_f
|
|
304
|
-
total_credits_to_refund = (pack.total_credits * refund_ratio).ceil
|
|
305
|
-
|
|
306
|
-
# Calculate credits already refunded (for incremental/partial refunds)
|
|
307
|
-
already_refunded = credits_previously_refunded
|
|
308
|
-
|
|
309
|
-
# Only deduct the INCREMENTAL amount (difference between what should be refunded and what's already refunded)
|
|
310
|
-
credits_to_remove = total_credits_to_refund - already_refunded
|
|
311
|
-
|
|
312
|
-
# Skip if nothing new to refund
|
|
313
|
-
if credits_to_remove <= 0
|
|
314
|
-
Rails.logger.info "Refund for charge #{id} already processed (#{already_refunded} credits already refunded)"
|
|
315
|
-
return
|
|
316
|
-
end
|
|
317
|
-
|
|
318
353
|
begin
|
|
319
|
-
|
|
354
|
+
wallet = credit_wallet
|
|
355
|
+
already_refunded = nil
|
|
356
|
+
total_credits_to_refund = divide_rounding_up(
|
|
357
|
+
total_purchased_credits * amount_refunded.to_i,
|
|
358
|
+
amount.to_i
|
|
359
|
+
)
|
|
320
360
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
361
|
+
# The cumulative lookup and the clawback are one wallet command: both
|
|
362
|
+
# read the ledger as it is now, under the wallet lock, so concurrent or
|
|
363
|
+
# repeated refund webhooks for this charge apply only the outstanding
|
|
364
|
+
# delta, never the same clawback twice, even when this callback runs
|
|
365
|
+
# inside a transaction that read the ledger first.
|
|
366
|
+
refund_transaction = wallet.deduct_refunded_credits(
|
|
367
|
+
total_credits_to_refund,
|
|
368
|
+
fulfillment: fulfillment,
|
|
369
|
+
metadata: snapshot.merge(
|
|
325
370
|
refunded_purchase_charge_id: id,
|
|
326
371
|
credits_refunded: true,
|
|
327
372
|
refunded_at: Time.current,
|
|
328
|
-
refund_percentage:
|
|
373
|
+
refund_percentage: amount_refunded.to_f / amount.to_f,
|
|
329
374
|
refund_amount_cents: amount_refunded,
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
)
|
|
375
|
+
total_credits_refunded: total_credits_to_refund
|
|
376
|
+
)
|
|
377
|
+
) { |refunds| already_refunded = credits_previously_refunded(refunds) }
|
|
335
378
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
rescue StandardError => e
|
|
379
|
+
if refund_transaction
|
|
380
|
+
Rails.logger.info "Processed refund for charge #{id}: #{-refund_transaction.amount} credits (incremental from #{already_refunded} to #{total_credits_to_refund})"
|
|
381
|
+
else
|
|
382
|
+
Rails.logger.info "Refund for charge #{id} already processed (#{already_refunded} credits already refunded)"
|
|
383
|
+
end
|
|
384
|
+
rescue => e
|
|
343
385
|
Rails.logger.error "Failed to process refund for charge #{id}: #{e.message}"
|
|
344
386
|
raise
|
|
345
387
|
end
|
|
346
388
|
end
|
|
347
389
|
|
|
390
|
+
def divide_rounding_up(numerator, denominator)
|
|
391
|
+
numerator.div(denominator) + (numerator.remainder(denominator).zero? ? 0 : 1)
|
|
392
|
+
end
|
|
348
393
|
end
|
|
349
394
|
end
|