usage_credits 0.1.1 → 0.2.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/.simplecov +48 -0
- data/AGENTS.md +5 -0
- data/Appraisals +25 -0
- data/CHANGELOG.md +16 -0
- data/CLAUDE.md +5 -0
- data/README.md +77 -6
- data/gemfiles/pay_10.0.gemfile +29 -0
- data/gemfiles/pay_11.0.gemfile +29 -0
- data/gemfiles/pay_8.3.gemfile +29 -0
- data/gemfiles/pay_9.0.gemfile +29 -0
- data/lib/generators/usage_credits/templates/initializer.rb +30 -2
- data/lib/usage_credits/configuration.rb +50 -3
- data/lib/usage_credits/helpers/period_parser.rb +43 -5
- data/lib/usage_credits/models/allocation.rb +2 -0
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +92 -44
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +376 -33
- data/lib/usage_credits/models/credit_subscription_plan.rb +115 -14
- data/lib/usage_credits/models/transaction.rb +1 -0
- data/lib/usage_credits/models/wallet.rb +15 -10
- data/lib/usage_credits/services/fulfillment_service.rb +15 -6
- data/lib/usage_credits/version.rb +1 -1
- metadata +119 -10
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module UsageCredits
|
|
4
4
|
# Extends Pay::Charge with credit pack functionality
|
|
5
|
+
#
|
|
6
|
+
# This extension integrates with the Pay gem (https://github.com/pay-rails/pay)
|
|
7
|
+
# to automatically fulfill credit packs when charges succeed and handle refunds
|
|
8
|
+
# when charges are refunded.
|
|
5
9
|
module PayChargeExtension
|
|
6
10
|
extend ActiveSupport::Concern
|
|
7
11
|
|
|
@@ -20,14 +24,18 @@ module UsageCredits
|
|
|
20
24
|
case type
|
|
21
25
|
when "Pay::Stripe::Charge"
|
|
22
26
|
status = data["status"] || data[:status]
|
|
27
|
+
# Explicitly check for failure states
|
|
28
|
+
return false if status == "failed"
|
|
29
|
+
return false if status == "pending"
|
|
30
|
+
return false if status == "canceled"
|
|
23
31
|
return true if status == "succeeded"
|
|
24
|
-
|
|
32
|
+
# Fallback: check if amount was actually captured
|
|
33
|
+
return data["amount_captured"].to_i == amount.to_i && amount.to_i.positive?
|
|
25
34
|
end
|
|
26
35
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# TODO: Implement for more payment processors? Or just drop this method if it's unnecessary
|
|
36
|
+
# For non-Stripe charges, we assume Pay only creates charges after successful payment
|
|
37
|
+
# This is a reasonable assumption based on Pay gem's behavior
|
|
38
|
+
# TODO: Implement for more payment processors if needed
|
|
31
39
|
true
|
|
32
40
|
end
|
|
33
41
|
|
|
@@ -39,10 +47,14 @@ module UsageCredits
|
|
|
39
47
|
private
|
|
40
48
|
|
|
41
49
|
# Returns true if the charge has a valid credit wallet to operate on
|
|
50
|
+
# NOTE: We use original_credit_wallet to avoid auto-creating a wallet via ensure_credit_wallet
|
|
42
51
|
def has_valid_wallet?
|
|
43
52
|
return false unless customer&.owner&.respond_to?(:credit_wallet)
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
# Check for existing wallet without triggering auto-creation
|
|
54
|
+
if customer.owner.respond_to?(:original_credit_wallet)
|
|
55
|
+
return customer.owner.original_credit_wallet.present?
|
|
56
|
+
end
|
|
57
|
+
customer.owner.credit_wallet.present?
|
|
46
58
|
end
|
|
47
59
|
|
|
48
60
|
def credit_wallet
|
|
@@ -124,7 +136,8 @@ module UsageCredits
|
|
|
124
136
|
end
|
|
125
137
|
|
|
126
138
|
begin
|
|
127
|
-
# Wrap
|
|
139
|
+
# Wrap in transaction to ensure atomicity - if Fulfillment.create! fails,
|
|
140
|
+
# the credits should NOT be added. This is critical for money handling.
|
|
128
141
|
ActiveRecord::Base.transaction do
|
|
129
142
|
# Add credits to the user's wallet
|
|
130
143
|
credit_wallet.add_credits(
|
|
@@ -163,39 +176,64 @@ module UsageCredits
|
|
|
163
176
|
end
|
|
164
177
|
end
|
|
165
178
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
179
|
+
# Returns the total credits already refunded for this charge
|
|
180
|
+
# Note: NOT memoized because refunds can happen incrementally within the same request
|
|
181
|
+
def credits_previously_refunded
|
|
169
182
|
transactions = credit_wallet&.transactions&.where(category: "credit_pack_refund")
|
|
170
|
-
return
|
|
183
|
+
return 0 unless transactions.present?
|
|
171
184
|
|
|
185
|
+
# Try database-level filtering first (more efficient)
|
|
172
186
|
begin
|
|
173
187
|
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
174
188
|
if adapter.include?("postgres")
|
|
175
|
-
|
|
189
|
+
# PostgreSQL supports the @> JSON containment operator
|
|
190
|
+
filtered = transactions.where(
|
|
191
|
+
"metadata @> ?",
|
|
192
|
+
{ refunded_purchase_charge_id: id, credits_refunded: true }.to_json
|
|
193
|
+
)
|
|
194
|
+
return filtered.sum { |tx| -tx.amount }
|
|
176
195
|
else
|
|
177
|
-
|
|
196
|
+
# SQLite/MySQL with JSON_EXTRACT
|
|
197
|
+
filtered = transactions.where(
|
|
198
|
+
"json_extract(metadata, '$.refunded_purchase_charge_id') = ? AND json_extract(metadata, '$.credits_refunded') = ?",
|
|
199
|
+
id, true
|
|
200
|
+
)
|
|
201
|
+
return filtered.sum { |tx| -tx.amount }
|
|
178
202
|
end
|
|
179
|
-
rescue ActiveRecord::StatementInvalid
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
203
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
204
|
+
Rails.logger.warn "JSON query failed, falling back to Ruby filtering: #{e.message}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Fallback: filter in Ruby (for databases without JSON support)
|
|
208
|
+
# Sum in a single pass to avoid multiple iterations
|
|
209
|
+
transactions.sum do |tx|
|
|
210
|
+
data = tx.metadata.is_a?(Hash) ? tx.metadata : (JSON.parse(tx.metadata) rescue {})
|
|
211
|
+
if data["refunded_purchase_charge_id"].to_i == id.to_i && data["credits_refunded"].to_s == "true"
|
|
212
|
+
-tx.amount
|
|
213
|
+
else
|
|
214
|
+
0
|
|
188
215
|
end
|
|
189
216
|
end
|
|
190
217
|
end
|
|
191
218
|
|
|
219
|
+
def credits_already_refunded?
|
|
220
|
+
# Check if any refund was already processed for this charge
|
|
221
|
+
credits_previously_refunded > 0
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def fully_refunded?
|
|
225
|
+
# Check if a full refund (100%) has already been processed
|
|
226
|
+
pack = UsageCredits.find_pack(pack_identifier&.to_sym)
|
|
227
|
+
return false unless pack
|
|
228
|
+
credits_previously_refunded >= pack.total_credits
|
|
229
|
+
end
|
|
230
|
+
|
|
192
231
|
def handle_refund!
|
|
193
232
|
# Guard clauses for required data and state
|
|
194
233
|
return unless refunded?
|
|
195
234
|
return unless pack_identifier
|
|
196
235
|
return unless has_valid_wallet?
|
|
197
236
|
return unless amount.is_a?(Numeric) && amount.positive?
|
|
198
|
-
return if credits_already_refunded?
|
|
199
237
|
|
|
200
238
|
pack_name = pack_identifier.to_sym
|
|
201
239
|
pack = UsageCredits.find_pack(pack_name)
|
|
@@ -211,29 +249,39 @@ module UsageCredits
|
|
|
211
249
|
return
|
|
212
250
|
end
|
|
213
251
|
|
|
214
|
-
# Calculate
|
|
215
|
-
# Always use ceil for credit calculations to avoid giving more credits than paid for
|
|
252
|
+
# Calculate total credits that SHOULD be refunded based on current refund amount
|
|
216
253
|
refund_ratio = amount_refunded.to_f / amount.to_f
|
|
217
|
-
|
|
254
|
+
total_credits_to_refund = (pack.total_credits * refund_ratio).ceil
|
|
218
255
|
|
|
219
|
-
|
|
220
|
-
|
|
256
|
+
# Calculate credits already refunded (for incremental/partial refunds)
|
|
257
|
+
already_refunded = credits_previously_refunded
|
|
221
258
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
259
|
+
# Only deduct the INCREMENTAL amount (difference between what should be refunded and what's already refunded)
|
|
260
|
+
credits_to_remove = total_credits_to_refund - already_refunded
|
|
261
|
+
|
|
262
|
+
# Skip if nothing new to refund
|
|
263
|
+
if credits_to_remove <= 0
|
|
264
|
+
Rails.logger.info "Refund for charge #{id} already processed (#{already_refunded} credits already refunded)"
|
|
265
|
+
return
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
begin
|
|
269
|
+
Rails.logger.info "Processing refund for charge #{id}: #{credits_to_remove} credits (incremental from #{already_refunded} to #{total_credits_to_refund})"
|
|
270
|
+
|
|
271
|
+
credit_wallet.deduct_credits(
|
|
272
|
+
credits_to_remove,
|
|
273
|
+
category: "credit_pack_refund",
|
|
274
|
+
metadata: {
|
|
275
|
+
refunded_purchase_charge_id: id,
|
|
276
|
+
credits_refunded: true,
|
|
277
|
+
refunded_at: Time.current,
|
|
278
|
+
refund_percentage: refund_ratio,
|
|
279
|
+
refund_amount_cents: amount_refunded,
|
|
280
|
+
incremental_credits: credits_to_remove,
|
|
281
|
+
total_credits_refunded: total_credits_to_refund,
|
|
282
|
+
**pack.base_metadata
|
|
283
|
+
}
|
|
284
|
+
)
|
|
237
285
|
|
|
238
286
|
Rails.logger.info "Successfully processed refund for charge #{id}"
|
|
239
287
|
rescue UsageCredits::InsufficientCredits => e
|