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.
@@ -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
- return true if data["amount_captured"].to_i == amount.to_i
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
- # Are Pay::Charge objects guaranteed to be created only after a successful payment?
28
- # Is the existence of a Pay::Charge object in the database a guarantee that the payment went through?
29
- # I'm implementing this `succeeded?` method just out of precaution
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
- return false unless customer.owner.credit_wallet.present?
45
- true
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 credit addition in a transaction for atomicity
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
- def credits_already_refunded?
167
- # Check if refund was already processed with credits deducted by looking for a refund transaction
168
- # Look up transactions for a refund.
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 false unless transactions.present?
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
- transactions.exists?(['metadata @> ?', { refunded_purchase_charge_id: id, credits_refunded: true }.to_json])
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
- transactions.exists?(["json_extract(metadata, '$.refunded_purchase_charge_id') = ? AND json_extract(metadata, '$.credits_refunded') = ?", id, true])
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
- transactions.any? do |tx|
181
- data =
182
- if tx.metadata.is_a?(Hash)
183
- tx.metadata
184
- else
185
- JSON.parse(tx.metadata) rescue {}
186
- end
187
- data["refunded_purchase_charge_id"].to_i == id.to_i && data["credits_refunded"].to_s == "true"
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 refund ratio and credits to remove
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
- credits_to_remove = (pack.total_credits * refund_ratio).ceil
254
+ total_credits_to_refund = (pack.total_credits * refund_ratio).ceil
218
255
 
219
- begin
220
- Rails.logger.info "Processing refund for charge #{id}: #{credits_to_remove} credits (#{(refund_ratio * 100).round(2)}% of #{pack.total_credits})"
256
+ # Calculate credits already refunded (for incremental/partial refunds)
257
+ already_refunded = credits_previously_refunded
221
258
 
222
- # Wrap credit deduction in a transaction for atomicity
223
- ActiveRecord::Base.transaction do
224
- credit_wallet.deduct_credits(
225
- credits_to_remove,
226
- category: "credit_pack_refund",
227
- metadata: {
228
- refunded_purchase_charge_id: id,
229
- credits_refunded: true,
230
- refunded_at: Time.current,
231
- refund_percentage: refund_ratio,
232
- refund_amount_cents: amount_refunded,
233
- **pack.base_metadata
234
- }
235
- )
236
- end
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