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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +120 -29
  4. data/lib/generators/usage_credits/install_generator.rb +9 -1
  5. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +64 -20
  6. data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
  7. data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
  8. data/lib/usage_credits/callbacks.rb +9 -54
  9. data/lib/usage_credits/configuration.rb +25 -13
  10. data/lib/usage_credits/core_ext/numeric.rb +12 -16
  11. data/lib/usage_credits/cost/base.rb +1 -6
  12. data/lib/usage_credits/cost/compound.rb +3 -2
  13. data/lib/usage_credits/cost/fixed.rb +1 -2
  14. data/lib/usage_credits/cost/variable.rb +23 -8
  15. data/lib/usage_credits/engine.rb +8 -12
  16. data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
  17. data/lib/usage_credits/helpers/credits_helper.rb +9 -11
  18. data/lib/usage_credits/helpers/period_parser.rb +31 -9
  19. data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
  20. data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
  21. data/lib/usage_credits/models/allocation.rb +15 -20
  22. data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
  23. data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
  24. data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
  25. data/lib/usage_credits/models/credit_pack.rb +34 -13
  26. data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
  27. data/lib/usage_credits/models/fulfillment.rb +23 -48
  28. data/lib/usage_credits/models/operation.rb +36 -28
  29. data/lib/usage_credits/models/transaction.rb +51 -158
  30. data/lib/usage_credits/models/transfer.rb +37 -0
  31. data/lib/usage_credits/models/wallet.rb +219 -314
  32. data/lib/usage_credits/railtie.rb +4 -13
  33. data/lib/usage_credits/services/fulfillment_service.rb +146 -22
  34. data/lib/usage_credits/subscription_terms.rb +131 -0
  35. data/lib/usage_credits/version.rb +1 -1
  36. data/lib/usage_credits.rb +16 -3
  37. metadata +33 -20
  38. data/.rubocop.yml +0 -8
  39. data/.simplecov +0 -37
  40. data/AGENTS.md +0 -5
  41. data/Appraisals +0 -43
  42. data/CLAUDE.md +0 -5
  43. data/Rakefile +0 -32
  44. data/gemfiles/pay_10.0.gemfile +0 -41
  45. data/gemfiles/pay_11.0.gemfile +0 -41
  46. data/gemfiles/pay_8.3.gemfile +0 -41
  47. data/gemfiles/pay_9.0.gemfile +0 -41
  48. data/gemfiles/rails_7.2.gemfile +0 -41
  49. data/gemfiles/rails_8.1.gemfile +0 -41
@@ -31,13 +31,14 @@ module UsageCredits
31
31
  included do
32
32
  # For initial setup and fulfillment, we can't do after_create or on: :create because the subscription first may
33
33
  # get created with status "incomplete" and only get updated to status "active" when the payment is cleared
34
- after_commit :handle_initial_award_and_fulfillment_setup
35
-
36
- after_commit :update_fulfillment_on_renewal, if: :subscription_renewed?
37
- after_commit :update_fulfillment_on_cancellation, if: :subscription_canceled?
38
- after_commit :handle_plan_change_wrapper
39
-
40
- # TODO: handle paused subscriptions (may still have an "active" status?)
34
+ after_commit :handle_initial_award_and_fulfillment_setup, on: [:create, :update]
35
+
36
+ after_commit :update_fulfillment_on_renewal, on: :update, if: :subscription_renewed?
37
+ after_commit :update_fulfillment_on_cancellation, on: :update, if: :subscription_canceled?
38
+ after_commit :handle_plan_change_wrapper, on: :update
39
+ after_commit :apply_deferred_plan_change_after_resume,
40
+ on: :update,
41
+ if: :usage_credit_resume_state_changed?
41
42
  end
42
43
 
43
44
  # Identify the usage_credits plan object
@@ -47,11 +48,41 @@ module UsageCredits
47
48
  end
48
49
 
49
50
  def provides_credits?
50
- credit_subscription_plan.present?
51
+ subscription_terms.present?
51
52
  end
52
53
 
53
54
  def fulfillment_should_stop_at
54
- (ends_at || current_period_end)
55
+ ends_at || current_period_end
56
+ end
57
+
58
+ # Pay's processor-specific #active? implementation is the source of truth
59
+ # for grace periods and effective pauses. Pay::Subscription itself does not
60
+ # implement #paused?, however, so legacy/base-class rows need the equivalent
61
+ # lifecycle check without calling Pay's otherwise unsafe base #active?.
62
+ # In particular, Stripe keeps the raw status as "active" while a void pause
63
+ # is in effect.
64
+ def eligible_for_usage_credit_fulfillment?(include_trial: false)
65
+ processor_active = if status == "on_trial"
66
+ respond_to?(:on_trial?) && on_trial?
67
+ elsif respond_to?(:paused?)
68
+ active?
69
+ else
70
+ ["trialing", "active"].include?(status) && !ended?
71
+ end
72
+
73
+ return false unless processor_active
74
+ return false if paused_for_usage_credits?
75
+
76
+ include_trial || !trialing_for_credits?
77
+ end
78
+
79
+ # Reconcile the initial/trial award state on demand. The recurring service
80
+ # uses this at lifecycle boundaries for processors (notably Braintree,
81
+ # whose status can remain `active` throughout a trial) and to finish a
82
+ # deferred plan change before any recurring credits can be minted.
83
+ def sync_usage_credit_fulfillment!
84
+ handle_initial_award_and_fulfillment_setup
85
+ apply_deferred_plan_change_after_resume
55
86
  end
56
87
 
57
88
  private
@@ -64,16 +95,14 @@ module UsageCredits
64
95
  end
65
96
 
66
97
  def credits_already_fulfilled?
67
- # TODO: There's a race condition where Pay actually updates the subscription two times on initial creation,
68
- # leading to us triggering handle_initial_award_and_fulfillment_setup twice too.
69
- # Since no Fulfillment record has been created yet, both callbacks will try to create the same Fulfillment object
70
- # at about the same time, thus making this check useless (there's nothing written to the DB yet)
71
- # For now, we handle it by adding a validation to the Fulfillment model so that there's no two Fulfillment objects
72
- # with the same source_id -- so whichever of the two callbacks gets processed first wins, the other just fails.
73
- # That's how we prevent double credit awarding for now, but this race condition should be handled more elegantly.
74
98
  fulfillment = UsageCredits::Fulfillment.find_by(source: self)
75
99
  return false unless fulfillment
76
100
 
101
+ # Deferred terms own resume reconciliation. Treat the fulfillment as
102
+ # initialized until those terms are atomically installed so the generic
103
+ # reactivation path cannot mint an unintended extra first cycle.
104
+ return true if fulfillment.metadata.key?("deferred_plan_change")
105
+
77
106
  # A stopped fulfillment (stops_at in the past) should NOT prevent reactivation
78
107
  # This handles: credit → non-credit → credit transitions (after stop date)
79
108
  return false if fulfillment.stops_at.present? && fulfillment.stops_at <= Time.current
@@ -82,7 +111,27 @@ module UsageCredits
82
111
  # should also allow reactivation - user changed their mind before the stop took effect
83
112
  return false if fulfillment.metadata["stopped_reason"].present?
84
113
 
85
- true
114
+ initial_award_completed?(fulfillment)
115
+ end
116
+
117
+ def initial_award_completed?(fulfillment)
118
+ award_state = fulfillment.metadata["initial_award_state"]
119
+
120
+ if trialing_for_credits?
121
+ # Any existing trial/active initial award makes a trial callback
122
+ # idempotent. Active subscriptions never move backwards into trial.
123
+ true
124
+ elsif status == "active"
125
+ return true if award_state == "active"
126
+ return false if award_state == "trial" || fulfillment.metadata["trial"]
127
+
128
+ # Pre-1.0 fulfillments do not carry initial_award_state. Conservatively
129
+ # treat a non-trial legacy row as already active to avoid over-crediting
130
+ # existing customers during upgrade.
131
+ true
132
+ else
133
+ false
134
+ end
86
135
  end
87
136
 
88
137
  # Returns an existing fulfillment that is stopped or scheduled to stop
@@ -102,7 +151,8 @@ module UsageCredits
102
151
  end
103
152
 
104
153
  def subscription_renewed?
105
- (saved_change_to_ends_at? || saved_change_to_current_period_end?) && status == "active"
154
+ (saved_change_to_ends_at? || saved_change_to_current_period_end?) &&
155
+ eligible_for_usage_credit_fulfillment?
106
156
  end
107
157
 
108
158
  # This doesn't get called the exact moment the user cancels its subscription, but at the end of the period,
@@ -114,7 +164,8 @@ module UsageCredits
114
164
  end
115
165
 
116
166
  def plan_changed?
117
- return false unless saved_change_to_processor_plan? && status == "active"
167
+ return false unless saved_change_to_processor_plan?
168
+ return false unless eligible_for_usage_credit_fulfillment? || paused_for_usage_credits?
118
169
 
119
170
  # The old plan ID must be present (not nil) - otherwise this is initial subscription creation
120
171
  # not a plan change. Initial subscription is handled by handle_initial_award_and_fulfillment_setup.
@@ -125,7 +176,8 @@ module UsageCredits
125
176
  # If old plan wasn't a credit plan (not in config), then handle_initial_award_and_fulfillment_setup
126
177
  # will handle the "fresh start" case - we don't want to double-award credits.
127
178
  old_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(old_plan_id)
128
- return false unless old_plan.present?
179
+ fulfillment = UsageCredits::Fulfillment.find_by(source: self)
180
+ return false unless old_plan.present? || fulfillment.present?
129
181
 
130
182
  # At this point, old plan provided credits. We handle:
131
183
  # - Credit → Credit (upgrade/downgrade)
@@ -139,69 +191,119 @@ module UsageCredits
139
191
 
140
192
  # Immediate awarding of first cycle + set up Fulfillment object for subsequent periods
141
193
  def handle_initial_award_and_fulfillment_setup
142
- return unless provides_credits?
194
+ plan = subscription_terms
195
+ return unless plan
143
196
  return unless has_valid_wallet?
144
197
 
145
- # We only do immediate awarding if the subscription is trialing or active
146
- return unless ["trialing", "active"].include?(status)
147
-
148
- # Check if we need to reactivate a stopped fulfillment (credit → non-credit → credit scenario)
149
- existing_reactivatable_fulfillment = reactivatable_fulfillment
150
- is_reactivation = existing_reactivatable_fulfillment.present?
198
+ # Pay normalizes trials and pauses differently per processor. Trust its
199
+ # active predicate instead of the raw status, which remains "active" for
200
+ # an effective Stripe void pause.
201
+ return unless eligible_for_usage_credit_fulfillment?(include_trial: true)
151
202
 
152
203
  # Skip if we already have an ACTIVE fulfillment record
153
204
  return if credits_already_fulfilled?
154
205
 
155
- plan = credit_subscription_plan
156
206
  wallet = customer.owner.credit_wallet
157
207
 
158
208
  # Calculate credit expiration using the shared helper
159
209
  credits_expire_at = calculate_credit_expiration(plan, current_period_start)
160
210
 
161
- Rails.logger.info "Fulfilling #{is_reactivation ? 'reactivation' : 'initial'} credits for subscription #{id}"
162
- Rails.logger.info " Status: #{status}"
163
- Rails.logger.info " Plan: #{plan}"
164
-
165
211
  # Variables to track for callback dispatch after transaction commits
166
212
  total_credits_awarded = 0
167
213
  last_credit_transaction = nil
214
+ is_reactivation = false
168
215
 
169
216
  # Transaction for atomic awarding + fulfillment creation/reactivation
170
217
  # Callback is dispatched AFTER this block to ensure credits are persisted
171
- ActiveRecord::Base.transaction do
172
- transaction_ids = []
173
-
174
- # 1) If this is a trial and not an active subscription: award trial credits, if any
175
- if status == "trialing" && plan.trial_credits.positive?
218
+ self.class.transaction do
219
+ # Lock order for every subscription mutation is subscription →
220
+ # fulfillment → wallet. Serializing on the Pay row closes duplicate
221
+ # webhook races and prevents lock-order deadlocks with recurring jobs.
222
+ next unless lock_current_subscription_version
223
+ next unless eligible_for_usage_credit_fulfillment?(include_trial: true)
224
+ existing_fulfillment = UsageCredits::Fulfillment.lock.find_by(source: self)
225
+ wallet.lock!
226
+ next if existing_fulfillment&.metadata&.key?("deferred_plan_change")
227
+ next if existing_fulfillment && initial_award_completed?(existing_fulfillment) && !reactivatable_record?(existing_fulfillment)
228
+
229
+ is_reactivation = existing_fulfillment.present? && reactivatable_record?(existing_fulfillment)
230
+ is_trial_activation = existing_fulfillment.present? && status == "active" && !trialing_for_credits? && !is_reactivation
231
+
232
+ Rails.logger.info "Fulfilling #{is_reactivation ? "reactivation" : "initial"} credits for subscription #{id}"
233
+ Rails.logger.info " Status: #{status}"
234
+ Rails.logger.info " Plan: #{plan}"
235
+
236
+ # Create or reactivate the fulfillment before minting so every ledger
237
+ # row is linked at INSERT time. The enclosing transaction keeps the
238
+ # temporary zero amount invisible and rolls everything back together.
239
+ fulfilled_at = Time.current
240
+ next_fulfillment_at = next_subscription_fulfillment_at(plan)
241
+ award_state = trialing_for_credits? ? "trial" : "active"
242
+
243
+ fulfillment_record = if is_reactivation || is_trial_activation
244
+ existing_fulfillment.tap do |record|
245
+ record.update!(
246
+ credits_last_fulfillment: 0,
247
+ fulfillment_period: plan.fulfillment_period_display,
248
+ last_fulfilled_at: fulfilled_at,
249
+ next_fulfillment_at: next_fulfillment_at,
250
+ stops_at: fulfillment_should_stop_at,
251
+ metadata: record.metadata
252
+ .except(
253
+ "trial", "stopped_reason", "stopped_at", "stopped_plan",
254
+ "pending_plan_change", "pending_plan_snapshot", "plan_change_at"
255
+ )
256
+ .merge(plan_snapshot_metadata(plan))
257
+ .merge(
258
+ "subscription_id" => id,
259
+ "initial_award_state" => award_state,
260
+ (is_reactivation ? "reactivated_at" : "activated_at") => fulfilled_at
261
+ )
262
+ )
263
+ end
264
+ else
265
+ UsageCredits::Fulfillment.create!(
266
+ wallet: wallet,
267
+ source: self,
268
+ fulfillment_type: "subscription",
269
+ credits_last_fulfillment: 0,
270
+ fulfillment_period: plan.fulfillment_period_display,
271
+ last_fulfilled_at: fulfilled_at,
272
+ next_fulfillment_at: next_fulfillment_at,
273
+ stops_at: fulfillment_should_stop_at,
274
+ metadata: {
275
+ "subscription_id" => id,
276
+ "initial_award_state" => award_state,
277
+ "trial" => trialing_for_credits?
278
+ }.merge(plan_snapshot_metadata(plan))
279
+ )
280
+ end
176
281
 
177
- # Immediate awarding of trial credits
282
+ # If this is a trial and not an active subscription, award trial credits.
283
+ if trialing_for_credits? && plan.trial_credits.positive?
178
284
  last_credit_transaction = wallet.add_credits(plan.trial_credits,
179
285
  category: "subscription_trial",
180
286
  expires_at: trial_ends_at,
287
+ fulfillment: fulfillment_record,
181
288
  metadata: {
182
289
  subscription_id: id,
183
290
  reason: is_reactivation ? "reactivation_trial_credits" : "initial_trial_credits",
184
291
  plan: processor_plan,
185
- fulfilled_at: Time.current
186
- }
187
- )
188
- transaction_ids << last_credit_transaction.id
292
+ fulfilled_at: fulfilled_at
293
+ })
189
294
  total_credits_awarded += plan.trial_credits
190
-
191
295
  elsif status == "active"
192
-
193
296
  # Awarding of signup bonus, if any (only on initial setup, not reactivation)
194
297
  if plan.signup_bonus_credits.positive? && !is_reactivation
195
298
  bonus_transaction = wallet.add_credits(plan.signup_bonus_credits,
196
299
  category: "subscription_signup_bonus",
300
+ fulfillment: fulfillment_record,
197
301
  metadata: {
198
302
  subscription_id: id,
199
303
  reason: "signup_bonus",
200
304
  plan: processor_plan,
201
- fulfilled_at: Time.current
202
- }
203
- )
204
- transaction_ids << bonus_transaction.id
305
+ fulfilled_at: fulfilled_at
306
+ })
205
307
  total_credits_awarded += plan.signup_bonus_credits
206
308
  last_credit_transaction = bonus_transaction
207
309
  end
@@ -210,110 +312,74 @@ module UsageCredits
210
312
  if plan.credits_per_period.positive?
211
313
  credits_transaction = wallet.add_credits(plan.credits_per_period,
212
314
  category: "subscription_credits",
213
- expires_at: credits_expire_at, # This will be nil if credit rollover is enabled
315
+ expires_at: credits_expire_at,
316
+ fulfillment: fulfillment_record,
214
317
  metadata: {
215
318
  subscription_id: id,
216
319
  reason: is_reactivation ? "reactivation" : "first_cycle",
217
320
  plan: processor_plan,
218
- fulfilled_at: Time.current
219
- }
220
- )
221
- transaction_ids << credits_transaction.id
321
+ fulfilled_at: fulfilled_at
322
+ })
222
323
  total_credits_awarded += plan.credits_per_period
223
324
  last_credit_transaction = credits_transaction
224
325
  end
225
326
  end
226
327
 
227
- # 2) Create or reactivate Fulfillment record for subsequent awarding
228
- # Use current_period_start as the base time, falling back to Time.current
229
- period_start = if trial_ends_at && status == "trialing"
230
- trial_ends_at
231
- else
232
- current_period_start || Time.current
233
- end
234
-
235
- # Ensure next_fulfillment_at is in the future
236
- next_fulfillment_at = period_start + plan.parsed_fulfillment_period
237
- next_fulfillment_at = Time.current + plan.parsed_fulfillment_period if next_fulfillment_at <= Time.current
238
-
239
- if is_reactivation
240
- # Reactivate the existing stopped/scheduled-to-stop fulfillment
241
- # Merge metadata to preserve any custom keys while updating core fields
242
- # Use string keys consistently to avoid duplicates after JSON serialization
243
- existing_reactivatable_fulfillment.update!(
244
- credits_last_fulfillment: total_credits_awarded,
245
- fulfillment_period: plan.fulfillment_period_display,
246
- last_fulfilled_at: Time.current,
247
- next_fulfillment_at: next_fulfillment_at,
248
- stops_at: fulfillment_should_stop_at,
249
- metadata: existing_reactivatable_fulfillment.metadata
250
- .except("stopped_reason", "stopped_at", "pending_plan_change", "plan_change_at")
251
- .merge(
252
- "subscription_id" => id,
253
- "plan" => processor_plan,
254
- "reactivated_at" => Time.current
255
- )
256
- )
257
-
258
- Rails.logger.info "Reactivated fulfillment #{existing_reactivatable_fulfillment.id} for subscription #{id}"
259
- else
260
- # Create new fulfillment
261
- # Use string keys consistently to avoid duplicates after JSON serialization
262
- UsageCredits::Fulfillment.create!(
263
- wallet: wallet,
264
- source: self,
265
- fulfillment_type: "subscription",
266
- credits_last_fulfillment: total_credits_awarded,
267
- fulfillment_period: plan.fulfillment_period_display,
268
- last_fulfilled_at: Time.current,
269
- next_fulfillment_at: next_fulfillment_at,
270
- stops_at: fulfillment_should_stop_at, # Pre-emptively set when the fulfillment will stop, in case we miss a future event (like sub cancellation)
271
- metadata: {
272
- "subscription_id" => id,
273
- "plan" => processor_plan,
274
- }
275
- )
276
-
277
- Rails.logger.info "Initial fulfillment for subscription #{id} finished"
278
- end
279
-
280
- # Link created transactions to the fulfillment object for traceability
281
- fulfillment_record = UsageCredits::Fulfillment.find_by(source: self)
282
- UsageCredits::Transaction.where(id: transaction_ids).update_all(fulfillment_id: fulfillment_record&.id) if transaction_ids.any?
328
+ fulfillment_record.update!(credits_last_fulfillment: total_credits_awarded)
329
+ Rails.logger.info "Fulfillment #{fulfillment_record.id} updated for subscription #{id}"
283
330
  end
284
331
 
285
332
  # Dispatch callback AFTER transaction commits - ensures credits are persisted
286
333
  if total_credits_awarded > 0
287
- UsageCredits::Callbacks.dispatch(:subscription_credits_awarded,
288
- wallet: wallet,
289
- amount: total_credits_awarded,
290
- transaction: last_credit_transaction,
291
- metadata: {
292
- subscription_plan_name: plan.name,
293
- subscription: plan,
294
- pay_subscription: self,
295
- fulfillment_period: plan.fulfillment_period_display,
296
- is_reactivation: is_reactivation,
297
- status: status
298
- }
299
- )
334
+ ActiveRecord.after_all_transactions_commit do
335
+ UsageCredits::Callbacks.dispatch(:subscription_credits_awarded,
336
+ wallet: wallet,
337
+ amount: total_credits_awarded,
338
+ transaction: last_credit_transaction,
339
+ metadata: {
340
+ subscription_plan_name: plan.name,
341
+ subscription: plan.callback_plan,
342
+ pay_subscription: self,
343
+ fulfillment_period: plan.fulfillment_period_display,
344
+ is_reactivation: is_reactivation,
345
+ status: status
346
+ })
347
+ end
300
348
  end
301
-
302
349
  rescue => e
303
350
  Rails.logger.error "Failed to fulfill initial credits for subscription #{id}: #{e.message}"
304
351
  raise
305
352
  end
306
353
 
354
+ def reactivatable_record?(fulfillment)
355
+ (fulfillment.stops_at.present? && fulfillment.stops_at <= Time.current) ||
356
+ fulfillment.metadata["stopped_reason"].present?
357
+ end
358
+
359
+ def next_subscription_fulfillment_at(plan)
360
+ # Trial credits hand off at the actual processor trial boundary. Once
361
+ # active, credit cadence is intentionally independent of billing cadence
362
+ # (for example a monthly charge can grant credits daily).
363
+ trial_boundary = trial_ends_at || current_period_end
364
+ return trial_boundary if trialing_for_credits? && trial_boundary.present? && trial_boundary > Time.current
365
+
366
+ period_start = [current_period_start || Time.current, Time.current].max
367
+ candidate = period_start + plan.parsed_fulfillment_period
368
+ (candidate > Time.current) ? candidate : Time.current + plan.parsed_fulfillment_period
369
+ end
370
+
307
371
  # Handle subscription renewal (we received a new payment for another billing period)
308
372
  # Each time the subscription renews and ends_at moves forward,
309
373
  # we keep awarding credits because Fulfillment#stops_at also moves forward
310
374
  def update_fulfillment_on_renewal
311
- return unless provides_credits? && has_valid_wallet?
375
+ return unless has_valid_wallet?
312
376
 
313
377
  fulfillment = UsageCredits::Fulfillment.find_by(source: self)
314
378
  return unless fulfillment
315
379
 
316
- ActiveRecord::Base.transaction do
380
+ self.class.transaction do
381
+ next unless lock_current_subscription_version
382
+ fulfillment.lock!
317
383
  # Check if there's a pending plan change to apply
318
384
  if fulfillment.metadata["pending_plan_change"].present?
319
385
  apply_pending_plan_change(fulfillment)
@@ -324,32 +390,53 @@ module UsageCredits
324
390
  Rails.logger.info "Fulfillment #{fulfillment.id} stops_at updated to #{fulfillment.stops_at}"
325
391
  rescue => e
326
392
  Rails.logger.error "Failed to extend fulfillment period for subscription #{id}: #{e.message}"
327
- raise ActiveRecord::Rollback
393
+ raise
328
394
  end
329
395
  end
330
396
 
331
-
332
397
  # If the subscription is canceled, let's set the Fulfillment's stops_at so that the job won't keep awarding
333
398
  def update_fulfillment_on_cancellation
334
- plan = credit_subscription_plan
335
- return unless plan && has_valid_wallet?
399
+ return unless has_valid_wallet?
336
400
 
337
401
  fulfillment = UsageCredits::Fulfillment.find_by(source: self)
338
402
  return unless fulfillment
339
403
 
340
- ActiveRecord::Base.transaction do
404
+ self.class.transaction do
405
+ next unless lock_current_subscription_version
406
+ fulfillment.lock!
407
+ wallet = fulfillment.wallet
408
+ wallet.lock!
409
+
410
+ active_plan_id = fulfillment.metadata["plan"]
411
+ configured_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(active_plan_id)
412
+ terms = terms_from_fulfillment(
413
+ fulfillment,
414
+ expected_plan_id: active_plan_id,
415
+ configured_plan: configured_plan
416
+ ) || UsageCredits::SubscriptionTerms.from_plan(configured_plan, processor_plan_id: active_plan_id)
417
+
341
418
  # Subscription cancelled, so stop awarding credits in the future
342
- fulfillment.update!(stops_at: fulfillment_should_stop_at)
419
+ fulfillment_attributes = {stops_at: fulfillment_should_stop_at}
420
+ if terms&.expire_credits_on_cancel
421
+ expires_at = cancellation_credit_expiration_at(terms)
422
+ wallet.expire_fulfillment_credits!(fulfillment: fulfillment, expires_at: expires_at)
423
+ fulfillment_attributes[:metadata] = fulfillment.metadata.merge(
424
+ "cancellation_credit_expiration_at" => expires_at,
425
+ "cancellation_credit_expiration_applied_at" => Time.current
426
+ )
427
+ end
428
+
429
+ fulfillment.update!(fulfillment_attributes)
343
430
  Rails.logger.info "Fulfillment #{fulfillment.id} stops_at set to #{fulfillment.stops_at} due to cancellation"
344
431
  rescue => e
345
432
  Rails.logger.error "Failed to stop credit fulfillment for subscription #{id}: #{e.message}"
346
- raise ActiveRecord::Rollback
433
+ raise
347
434
  end
435
+ end
348
436
 
349
- # TODO: we can also expire already awarded credits here (without making the ledger mutable – we'll need to
350
- # check if the plan expires credits or not, and if rollover we may need to add a negative transaction to offset
351
- # the remaining balance)
352
-
437
+ def cancellation_credit_expiration_at(terms)
438
+ effective_cancellation_at = fulfillment_should_stop_at || Time.current
439
+ effective_cancellation_at + terms.credit_expiration_period_seconds.seconds
353
440
  end
354
441
 
355
442
  # Wrapper to check condition and call handle_plan_change
@@ -389,28 +476,41 @@ module UsageCredits
389
476
  Rails.logger.info " Looking up current plan: #{current_plan_id}"
390
477
  Rails.logger.info " Looking up new plan: #{new_plan_id}"
391
478
 
392
- current_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(current_plan_id)
479
+ current_plan = terms_from_fulfillment(fulfillment, expected_plan_id: current_plan_id) ||
480
+ terms_from_config(current_plan_id)
393
481
  new_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(new_plan_id)
394
482
 
395
483
  Rails.logger.info " Current plan found: #{current_plan&.name} (#{current_plan&.credits_per_period} credits)"
396
484
  Rails.logger.info " New plan found: #{new_plan&.name} (#{new_plan&.credits_per_period} credits)"
397
485
 
486
+ # A processor can change plans while service is paused. Persist the new
487
+ # immutable terms, but never mint an upgrade until service resumes. A
488
+ # later resume callback atomically installs the deferred terms; the next
489
+ # normal fulfillment then uses them.
490
+ if paused_for_usage_credits?
491
+ defer_plan_change_until_resume(fulfillment, new_plan)
492
+ return
493
+ end
494
+
398
495
  # Handle downgrade to a non-credit plan: schedule fulfillment stop for end of period
399
- if new_plan.nil? && current_plan.present?
496
+ if new_plan.nil? && current_plan_id.present?
400
497
  handle_downgrade_to_non_credit_plan(fulfillment)
401
498
  return
402
499
  end
403
500
 
404
501
  return unless new_plan # Neither current nor new plan provides credits - nothing to do
405
502
 
406
- ActiveRecord::Base.transaction do
503
+ self.class.transaction do
504
+ next unless lock_current_subscription_version
505
+ next unless eligible_for_usage_credit_fulfillment?
506
+ fulfillment.lock!
407
507
  # FIRST: Check if returning to current plan (canceling a pending change)
408
508
  # This must come first! Returning to current plan = no credits, just clear pending
409
509
  # This matches Stripe's billing: no new charge means no new credits
410
510
  if current_plan_id == new_plan_id
411
511
  Rails.logger.info " Action: Returning to current plan (clearing pending change)"
412
512
  clear_pending_plan_change(fulfillment)
413
- return
513
+ next
414
514
  end
415
515
 
416
516
  # Now compare credits to determine upgrade vs downgrade
@@ -430,12 +530,12 @@ module UsageCredits
430
530
  else
431
531
  # Same credits amount, different plan - update metadata immediately
432
532
  Rails.logger.info " Action: Same credits, different plan - updating metadata only"
433
- update_fulfillment_plan_metadata(fulfillment, new_plan_id)
533
+ update_fulfillment_plan_metadata(fulfillment, new_plan)
434
534
  end
435
535
  rescue => e
436
536
  Rails.logger.error "Failed to handle plan change for subscription #{id}: #{e.message}"
437
537
  Rails.logger.error e.backtrace.join("\n")
438
- raise ActiveRecord::Rollback
538
+ raise
439
539
  end
440
540
 
441
541
  Rails.logger.info " Plan change completed successfully"
@@ -451,9 +551,9 @@ module UsageCredits
451
551
  Rails.logger.info " [UPGRADE] New plan period: #{new_plan.fulfillment_period_display}"
452
552
 
453
553
  # Calculate expiration using shared helper (uses current_period_end for upgrades)
454
- credits_expire_at = calculate_credit_expiration(new_plan, current_period_end)
554
+ credits_expire_at = calculate_credit_expiration(new_plan, Time.current)
455
555
 
456
- Rails.logger.info " [UPGRADE] Credits expire at: #{credits_expire_at || 'never (rollover enabled)'}"
556
+ Rails.logger.info " [UPGRADE] Credits expire at: #{credits_expire_at || "never (rollover enabled)"}"
457
557
 
458
558
  # Calculate next fulfillment time based on the NEW plan's period
459
559
  # This ensures the fulfillment schedule matches the new plan's cadence
@@ -463,13 +563,14 @@ module UsageCredits
463
563
  # The callback should only fire after ALL operations succeed
464
564
  upgrade_transaction = nil
465
565
 
466
- ActiveRecord::Base.transaction do
566
+ fulfillment.class.transaction do
467
567
  # Grant full new plan credits immediately
468
568
  # Use string keys consistently to avoid duplicates after JSON serialization
469
569
  upgrade_transaction = wallet.add_credits(
470
570
  new_plan.credits_per_period,
471
571
  category: "subscription_upgrade",
472
572
  expires_at: credits_expire_at,
573
+ fulfillment: fulfillment,
473
574
  metadata: {
474
575
  "subscription_id" => id,
475
576
  "plan" => processor_plan,
@@ -489,27 +590,30 @@ module UsageCredits
489
590
  # to ensure future fulfillments happen on the correct schedule
490
591
  # Use string keys consistently to avoid duplicates after JSON serialization
491
592
  fulfillment.update!(
593
+ credits_last_fulfillment: new_plan.credits_per_period,
594
+ last_fulfilled_at: Time.current,
492
595
  fulfillment_period: new_plan.fulfillment_period_display,
493
596
  next_fulfillment_at: next_fulfillment_at,
494
597
  metadata: fulfillment.metadata
495
- .except("pending_plan_change", "plan_change_at")
496
- .merge("plan" => processor_plan)
598
+ .except("pending_plan_change", "pending_plan_snapshot", "plan_change_at")
599
+ .merge(plan_snapshot_metadata(new_plan))
497
600
  )
498
601
  end
499
602
 
500
603
  # Dispatch callback AFTER transaction commits - ensures credits are persisted
501
- UsageCredits::Callbacks.dispatch(:subscription_credits_awarded,
502
- wallet: wallet,
503
- amount: new_plan.credits_per_period,
504
- transaction: upgrade_transaction,
505
- metadata: {
506
- subscription_plan_name: new_plan.name,
507
- subscription: new_plan,
508
- pay_subscription: self,
509
- fulfillment_period: new_plan.fulfillment_period_display,
510
- reason: "plan_upgrade"
511
- }
512
- )
604
+ ActiveRecord.after_all_transactions_commit do
605
+ UsageCredits::Callbacks.dispatch(:subscription_credits_awarded,
606
+ wallet: wallet,
607
+ amount: new_plan.credits_per_period,
608
+ transaction: upgrade_transaction,
609
+ metadata: {
610
+ subscription_plan_name: new_plan.name,
611
+ subscription: new_plan,
612
+ pay_subscription: self,
613
+ fulfillment_period: new_plan.fulfillment_period_display,
614
+ reason: "plan_upgrade"
615
+ })
616
+ end
513
617
 
514
618
  Rails.logger.info " [UPGRADE] Credits awarded successfully"
515
619
  Rails.logger.info " [UPGRADE] New balance: #{wallet.reload.balance}"
@@ -529,6 +633,7 @@ module UsageCredits
529
633
  fulfillment.update!(
530
634
  metadata: fulfillment.metadata.merge(
531
635
  "pending_plan_change" => processor_plan,
636
+ "pending_plan_snapshot" => plan_snapshot_metadata(new_plan),
532
637
  "plan_change_at" => schedule_time
533
638
  )
534
639
  )
@@ -543,13 +648,16 @@ module UsageCredits
543
648
  # Ensure schedule_time is never in the past
544
649
  schedule_time = [current_period_end || Time.current, Time.current].max
545
650
 
546
- ActiveRecord::Base.transaction do
651
+ self.class.transaction do
652
+ next unless lock_current_subscription_version
653
+ fulfillment.lock!
547
654
  # Use string keys consistently to avoid duplicates after JSON serialization
548
655
  fulfillment.update!(
549
656
  stops_at: schedule_time,
550
657
  metadata: fulfillment.metadata.merge(
551
658
  "stopped_reason" => "downgrade_to_non_credit_plan",
552
- "stopped_at" => schedule_time
659
+ "stopped_at" => schedule_time,
660
+ "stopped_plan" => processor_plan
553
661
  )
554
662
  )
555
663
 
@@ -557,15 +665,21 @@ module UsageCredits
557
665
  rescue => e
558
666
  Rails.logger.error "Failed to handle downgrade to non-credit plan for subscription #{id}: #{e.message}"
559
667
  Rails.logger.error e.backtrace.join("\n")
560
- raise ActiveRecord::Rollback
668
+ raise
561
669
  end
562
670
  end
563
671
 
564
- def update_fulfillment_plan_metadata(fulfillment, new_plan_id)
565
- # Use string keys consistently to avoid duplicates after JSON serialization
566
- fulfillment.update!(
567
- metadata: fulfillment.metadata.merge("plan" => new_plan_id)
568
- )
672
+ def update_fulfillment_plan_metadata(fulfillment, new_plan)
673
+ attributes = {
674
+ metadata: fulfillment.metadata.merge(plan_snapshot_metadata(new_plan))
675
+ }
676
+
677
+ if fulfillment.fulfillment_period != new_plan.fulfillment_period_display
678
+ attributes[:fulfillment_period] = new_plan.fulfillment_period_display
679
+ attributes[:next_fulfillment_at] = Time.current + new_plan.parsed_fulfillment_period
680
+ end
681
+
682
+ fulfillment.update!(attributes)
569
683
  end
570
684
 
571
685
  # Clear any pending plan change metadata
@@ -574,32 +688,108 @@ module UsageCredits
574
688
  return unless fulfillment.metadata["pending_plan_change"].present?
575
689
 
576
690
  fulfillment.update!(
577
- metadata: fulfillment.metadata.except("pending_plan_change", "plan_change_at")
691
+ metadata: fulfillment.metadata.except("pending_plan_change", "pending_plan_snapshot", "plan_change_at")
578
692
  )
579
693
 
580
694
  Rails.logger.info "Subscription #{id} pending plan change cleared (returned to current plan)"
581
695
  end
582
696
 
697
+ def defer_plan_change_until_resume(fulfillment, new_plan)
698
+ self.class.transaction do
699
+ next unless lock_current_subscription_version
700
+ next unless paused_for_usage_credits?
701
+ fulfillment.lock!
702
+
703
+ if new_plan.nil?
704
+ fulfillment.update!(
705
+ stops_at: Time.current,
706
+ metadata: fulfillment.metadata
707
+ .except("deferred_plan_change", "deferred_plan_snapshot")
708
+ .merge(
709
+ "stopped_reason" => "paused_change_to_non_credit_plan",
710
+ "stopped_at" => Time.current,
711
+ "stopped_plan" => processor_plan
712
+ )
713
+ )
714
+ elsif fulfillment.metadata["plan"] == processor_plan && !reactivatable_record?(fulfillment)
715
+ fulfillment.update!(
716
+ metadata: fulfillment.metadata.except("deferred_plan_change", "deferred_plan_snapshot")
717
+ )
718
+ else
719
+ fulfillment.update!(
720
+ metadata: fulfillment.metadata.merge(
721
+ "deferred_plan_change" => processor_plan,
722
+ "deferred_plan_snapshot" => plan_snapshot_metadata(new_plan)
723
+ )
724
+ )
725
+ end
726
+ end
727
+ end
728
+
729
+ def apply_deferred_plan_change_after_resume
730
+ return unless eligible_for_usage_credit_fulfillment?
731
+
732
+ fulfillment = UsageCredits::Fulfillment.find_by(source: self)
733
+ return unless fulfillment&.metadata&.key?("deferred_plan_change")
734
+
735
+ self.class.transaction do
736
+ next unless lock_current_subscription_version
737
+ next unless eligible_for_usage_credit_fulfillment?
738
+ fulfillment.lock!
739
+
740
+ plan_id = fulfillment.metadata["deferred_plan_change"]
741
+ snapshot = fulfillment.metadata["deferred_plan_snapshot"]
742
+ unless plan_id.to_s == processor_plan.to_s && snapshot&.fetch("plan", nil).to_s == plan_id.to_s
743
+ raise UsageCredits::InvalidOperation,
744
+ "Deferred plan terms for Pay::Subscription #{id} do not match its current processor plan"
745
+ end
746
+
747
+ fulfillment.update!(
748
+ stops_at: fulfillment_should_stop_at,
749
+ fulfillment_period: snapshot.fetch("fulfillment_period"),
750
+ metadata: fulfillment.metadata
751
+ .except(
752
+ "deferred_plan_change",
753
+ "deferred_plan_snapshot",
754
+ "pending_plan_change",
755
+ "pending_plan_snapshot",
756
+ "plan_change_at",
757
+ "stopped_reason",
758
+ "stopped_at",
759
+ "stopped_plan"
760
+ )
761
+ .merge(snapshot)
762
+ )
763
+ end
764
+ end
765
+
583
766
  def apply_pending_plan_change(fulfillment)
584
767
  pending_plan = fulfillment.metadata["pending_plan_change"]
768
+ pending_snapshot = fulfillment.metadata["pending_plan_snapshot"]
769
+ pending_snapshot = nil unless pending_snapshot&.fetch("plan", nil) == pending_plan
770
+ configured_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(pending_plan)
585
771
 
586
772
  # Validate that the pending plan still exists in configuration
587
773
  # This handles the edge case where an admin removes a plan after a user scheduled a downgrade
588
- unless UsageCredits.configuration.find_subscription_plan_by_processor_id(pending_plan)
774
+ unless configured_plan || pending_snapshot.present?
589
775
  Rails.logger.error "Cannot apply pending plan change for subscription #{id}: plan '#{pending_plan}' not found in configuration"
590
776
  # Clear the invalid pending change to prevent repeated failures
591
777
  fulfillment.update!(
592
- metadata: fulfillment.metadata.except("pending_plan_change", "plan_change_at")
778
+ metadata: fulfillment.metadata.except("pending_plan_change", "pending_plan_snapshot", "plan_change_at")
593
779
  )
594
780
  return
595
781
  end
596
782
 
597
- # Update to the new plan and clear the pending change
598
- # Use string keys consistently to avoid duplicates after JSON serialization
783
+ snapshot = pending_snapshot.presence || plan_snapshot_metadata(configured_plan, plan_id: pending_plan)
784
+ period = snapshot.fetch("fulfillment_period")
785
+
786
+ # Update all cadence and quantity fields, not only the display metadata.
599
787
  fulfillment.update!(
788
+ fulfillment_period: period,
789
+ next_fulfillment_at: Time.current + UsageCredits::PeriodParser.parse_persisted_period(period),
600
790
  metadata: fulfillment.metadata
601
- .except("pending_plan_change", "plan_change_at")
602
- .merge("plan" => pending_plan)
791
+ .except("pending_plan_change", "pending_plan_snapshot", "plan_change_at")
792
+ .merge(snapshot)
603
793
  )
604
794
 
605
795
  Rails.logger.info "Applied pending plan change for subscription #{id}: now on #{pending_plan}"
@@ -631,5 +821,131 @@ module UsageCredits
631
821
  effective_base + fulfillment_period + effective_grace
632
822
  end
633
823
 
824
+ def lock_current_subscription_version
825
+ current = self.class.lock.find_by(id: id)
826
+ return false unless current
827
+
828
+ %i[
829
+ updated_at status processor_plan current_period_start current_period_end
830
+ trial_ends_at ends_at pause_starts_at pause_behavior pause_resumes_at metadata
831
+ ].all? do |attribute|
832
+ !has_attribute?(attribute) || database_equivalent_attribute?(
833
+ attribute,
834
+ current.public_send(attribute),
835
+ public_send(attribute)
836
+ )
837
+ end
838
+ end
839
+
840
+ # Processor timestamps can carry nanoseconds while Rails' timestamp
841
+ # columns persist microseconds. Comparing the raw callback object against
842
+ # the locked row would therefore reject the exact state that was just
843
+ # saved on adapters/platforms that leave the extra nanoseconds in memory.
844
+ # Preserve strict comparisons for every non-temporal value, and compare
845
+ # temporal values at the column's effective database precision.
846
+ def database_equivalent_attribute?(attribute, persisted_value, callback_value)
847
+ return true if persisted_value == callback_value
848
+
849
+ type = self.class.type_for_attribute(attribute.to_s)
850
+ return false unless [:datetime, :time].include?(type.type)
851
+ return false if persisted_value.nil? || callback_value.nil?
852
+
853
+ column_precision = self.class.columns_hash.fetch(attribute.to_s).precision
854
+ precision = (column_precision || 6).clamp(0, 9)
855
+ scale = 10**precision
856
+
857
+ (persisted_value.to_r * scale).floor == (callback_value.to_r * scale).floor
858
+ end
859
+
860
+ # Deferred terms only need callback-time reconciliation when a processor
861
+ # lifecycle field may have moved a subscription out of a pause. The
862
+ # recurring service still calls the reconciliation method directly as a
863
+ # recovery path, so unrelated subscription updates can skip its lookup.
864
+ def usage_credit_resume_state_changed?
865
+ %w[status pause_behavior pause_starts_at pause_resumes_at].any? do |attribute|
866
+ has_attribute?(attribute) && saved_change_to_attribute?(attribute)
867
+ end
868
+ end
869
+
870
+ def paused_for_usage_credits?
871
+ return true if status == "paused"
872
+
873
+ pause_started = respond_to?(:pause_starts_at) && pause_starts_at.present? && pause_starts_at <= Time.current
874
+ return true if pause_started
875
+
876
+ respond_to?(:paused?) && paused? &&
877
+ (!respond_to?(:on_grace_period?) || !on_grace_period?)
878
+ end
879
+
880
+ def trialing_for_credits?
881
+ return true if ["on_trial", "trialing"].include?(status)
882
+
883
+ transitioned_to_active = saved_change_to_status? && ["on_trial", "trialing"].include?(status_before_last_save)
884
+ return false if transitioned_to_active
885
+ return on_trial? if respond_to?(:on_trial?)
886
+
887
+ false
888
+ end
889
+
890
+ def plan_snapshot_metadata(plan, plan_id: processor_plan)
891
+ cancellation_expiration_seconds =
892
+ if plan.respond_to?(:credit_expiration_period_seconds)
893
+ plan.credit_expiration_period_seconds
894
+ else
895
+ plan.credit_expiration_period&.to_i || 0
896
+ end
897
+
898
+ {
899
+ "plan" => plan_id,
900
+ "plan_name" => plan.name,
901
+ "credits_per_period" => plan.credits_per_period,
902
+ "signup_bonus_credits" => plan.signup_bonus_credits,
903
+ "trial_credits" => plan.trial_credits,
904
+ "fulfillment_period" => plan.fulfillment_period_display,
905
+ "rollover_enabled" => plan.rollover_enabled,
906
+ "expire_credits_on_cancel" => plan.expire_credits_on_cancel,
907
+ "credit_expiration_period" => cancellation_expiration_seconds
908
+ }
909
+ end
910
+
911
+ def subscription_terms
912
+ configured_plan = credit_subscription_plan
913
+ fulfillment = UsageCredits::Fulfillment.find_by(source: self)
914
+
915
+ terms = terms_from_fulfillment(fulfillment, expected_plan_id: processor_plan, configured_plan: configured_plan)
916
+ return terms if terms
917
+
918
+ data = (metadata || {}).with_indifferent_access
919
+ metadata_plan = data[:processor_plan].presence
920
+ if data[:purchase_type] == "credit_subscription" && (metadata_plan.nil? || metadata_plan.to_s == processor_plan.to_s)
921
+ terms = terms_from_metadata(data, configured_plan: configured_plan)
922
+ return terms if terms
923
+ end
924
+
925
+ UsageCredits::SubscriptionTerms.from_plan(configured_plan, processor_plan_id: processor_plan)
926
+ end
927
+
928
+ def terms_from_fulfillment(fulfillment, expected_plan_id:, configured_plan: nil)
929
+ return unless fulfillment&.fulfillment_type == "subscription"
930
+ return unless fulfillment.metadata["plan"].to_s == expected_plan_id.to_s
931
+
932
+ terms_from_metadata(fulfillment.metadata, configured_plan: configured_plan, processor_plan_id: expected_plan_id)
933
+ end
934
+
935
+ def terms_from_config(plan_id)
936
+ plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(plan_id)
937
+ UsageCredits::SubscriptionTerms.from_plan(plan, processor_plan_id: plan_id)
938
+ end
939
+
940
+ def terms_from_metadata(data, configured_plan:, processor_plan_id: processor_plan)
941
+ UsageCredits::SubscriptionTerms.from_metadata(
942
+ data,
943
+ processor_plan_id: processor_plan_id,
944
+ configured_plan: configured_plan
945
+ )
946
+ rescue ArgumentError => e
947
+ Rails.logger.error "Invalid subscription terms for Pay::Subscription #{id}: #{e.message}"
948
+ nil
949
+ end
634
950
  end
635
951
  end