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
|
@@ -35,21 +35,22 @@ module UsageCredits
|
|
|
35
35
|
|
|
36
36
|
after_commit :update_fulfillment_on_renewal, if: :subscription_renewed?
|
|
37
37
|
after_commit :update_fulfillment_on_cancellation, if: :subscription_canceled?
|
|
38
|
+
after_commit :handle_plan_change_wrapper
|
|
38
39
|
|
|
39
|
-
# TODO: handle plan changes (upgrades / downgrades)
|
|
40
40
|
# TODO: handle paused subscriptions (may still have an "active" status?)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# Identify the usage_credits plan object
|
|
44
|
+
# NOTE: Not memoized because processor_plan can change, and we need the current value
|
|
44
45
|
def credit_subscription_plan
|
|
45
|
-
|
|
46
|
+
UsageCredits.configuration.find_subscription_plan_by_processor_id(processor_plan)
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
def provides_credits?
|
|
49
50
|
credit_subscription_plan.present?
|
|
50
51
|
end
|
|
51
52
|
|
|
52
|
-
def
|
|
53
|
+
def fulfillment_should_stop_at
|
|
53
54
|
(ends_at || current_period_end)
|
|
54
55
|
end
|
|
55
56
|
|
|
@@ -70,7 +71,34 @@ module UsageCredits
|
|
|
70
71
|
# For now, we handle it by adding a validation to the Fulfillment model so that there's no two Fulfillment objects
|
|
71
72
|
# with the same source_id -- so whichever of the two callbacks gets processed first wins, the other just fails.
|
|
72
73
|
# That's how we prevent double credit awarding for now, but this race condition should be handled more elegantly.
|
|
73
|
-
UsageCredits::Fulfillment.
|
|
74
|
+
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
|
75
|
+
return false unless fulfillment
|
|
76
|
+
|
|
77
|
+
# A stopped fulfillment (stops_at in the past) should NOT prevent reactivation
|
|
78
|
+
# This handles: credit → non-credit → credit transitions (after stop date)
|
|
79
|
+
return false if fulfillment.stops_at.present? && fulfillment.stops_at <= Time.current
|
|
80
|
+
|
|
81
|
+
# A fulfillment scheduled to stop (has stopped_reason but stops_at is in the future)
|
|
82
|
+
# should also allow reactivation - user changed their mind before the stop took effect
|
|
83
|
+
return false if fulfillment.metadata["stopped_reason"].present?
|
|
84
|
+
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns an existing fulfillment that is stopped or scheduled to stop
|
|
89
|
+
# Used for reactivation scenarios (credit → non-credit → credit)
|
|
90
|
+
def reactivatable_fulfillment
|
|
91
|
+
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
|
92
|
+
return nil unless fulfillment
|
|
93
|
+
|
|
94
|
+
# Fulfillment is reactivatable if:
|
|
95
|
+
# 1. stops_at is in the past (actually stopped), OR
|
|
96
|
+
# 2. stopped_reason is set (scheduled to stop, but not yet)
|
|
97
|
+
is_stopped = fulfillment.stops_at.present? && fulfillment.stops_at <= Time.current
|
|
98
|
+
is_scheduled_to_stop = fulfillment.metadata["stopped_reason"].present?
|
|
99
|
+
|
|
100
|
+
return nil unless is_stopped || is_scheduled_to_stop
|
|
101
|
+
fulfillment
|
|
74
102
|
end
|
|
75
103
|
|
|
76
104
|
def subscription_renewed?
|
|
@@ -85,6 +113,26 @@ module UsageCredits
|
|
|
85
113
|
saved_change_to_status? && status == "canceled"
|
|
86
114
|
end
|
|
87
115
|
|
|
116
|
+
def plan_changed?
|
|
117
|
+
return false unless saved_change_to_processor_plan? && status == "active"
|
|
118
|
+
|
|
119
|
+
# The old plan ID must be present (not nil) - otherwise this is initial subscription creation
|
|
120
|
+
# not a plan change. Initial subscription is handled by handle_initial_award_and_fulfillment_setup.
|
|
121
|
+
old_plan_id = saved_change_to_processor_plan[0]
|
|
122
|
+
return false if old_plan_id.nil?
|
|
123
|
+
|
|
124
|
+
# Only trigger plan_change if the OLD plan was a credit plan.
|
|
125
|
+
# If old plan wasn't a credit plan (not in config), then handle_initial_award_and_fulfillment_setup
|
|
126
|
+
# will handle the "fresh start" case - we don't want to double-award credits.
|
|
127
|
+
old_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(old_plan_id)
|
|
128
|
+
return false unless old_plan.present?
|
|
129
|
+
|
|
130
|
+
# At this point, old plan provided credits. We handle:
|
|
131
|
+
# - Credit → Credit (upgrade/downgrade)
|
|
132
|
+
# - Credit → Non-credit (stop fulfillment)
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
|
|
88
136
|
# =========================================
|
|
89
137
|
# Actual fulfillment logic
|
|
90
138
|
# =========================================
|
|
@@ -97,20 +145,24 @@ module UsageCredits
|
|
|
97
145
|
# We only do immediate awarding if the subscription is trialing or active
|
|
98
146
|
return unless ["trialing", "active"].include?(status)
|
|
99
147
|
|
|
100
|
-
#
|
|
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?
|
|
151
|
+
|
|
152
|
+
# Skip if we already have an ACTIVE fulfillment record
|
|
101
153
|
return if credits_already_fulfilled?
|
|
102
154
|
|
|
103
155
|
plan = credit_subscription_plan
|
|
104
156
|
wallet = customer.owner.credit_wallet
|
|
105
157
|
|
|
106
|
-
#
|
|
107
|
-
credits_expire_at =
|
|
158
|
+
# Calculate credit expiration using the shared helper
|
|
159
|
+
credits_expire_at = calculate_credit_expiration(plan, current_period_start)
|
|
108
160
|
|
|
109
|
-
Rails.logger.info "Fulfilling initial credits for subscription #{id}"
|
|
161
|
+
Rails.logger.info "Fulfilling #{is_reactivation ? 'reactivation' : 'initial'} credits for subscription #{id}"
|
|
110
162
|
Rails.logger.info " Status: #{status}"
|
|
111
163
|
Rails.logger.info " Plan: #{plan}"
|
|
112
164
|
|
|
113
|
-
# Transaction for atomic awarding + fulfillment creation
|
|
165
|
+
# Transaction for atomic awarding + fulfillment creation/reactivation
|
|
114
166
|
ActiveRecord::Base.transaction do
|
|
115
167
|
|
|
116
168
|
total_credits_awarded = 0
|
|
@@ -125,7 +177,7 @@ module UsageCredits
|
|
|
125
177
|
expires_at: trial_ends_at,
|
|
126
178
|
metadata: {
|
|
127
179
|
subscription_id: id,
|
|
128
|
-
reason: "initial_trial_credits",
|
|
180
|
+
reason: is_reactivation ? "reactivation_trial_credits" : "initial_trial_credits",
|
|
129
181
|
plan: processor_plan,
|
|
130
182
|
fulfilled_at: Time.current
|
|
131
183
|
}
|
|
@@ -135,8 +187,8 @@ module UsageCredits
|
|
|
135
187
|
|
|
136
188
|
elsif status == "active"
|
|
137
189
|
|
|
138
|
-
# Awarding of signup bonus, if any
|
|
139
|
-
if plan.signup_bonus_credits.positive?
|
|
190
|
+
# Awarding of signup bonus, if any (only on initial setup, not reactivation)
|
|
191
|
+
if plan.signup_bonus_credits.positive? && !is_reactivation
|
|
140
192
|
transaction = wallet.add_credits(plan.signup_bonus_credits,
|
|
141
193
|
category: "subscription_signup_bonus",
|
|
142
194
|
metadata: {
|
|
@@ -157,7 +209,7 @@ module UsageCredits
|
|
|
157
209
|
expires_at: credits_expire_at, # This will be nil if credit rollover is enabled
|
|
158
210
|
metadata: {
|
|
159
211
|
subscription_id: id,
|
|
160
|
-
reason: "first_cycle",
|
|
212
|
+
reason: is_reactivation ? "reactivation" : "first_cycle",
|
|
161
213
|
plan: processor_plan,
|
|
162
214
|
fulfilled_at: Time.current
|
|
163
215
|
}
|
|
@@ -167,37 +219,63 @@ module UsageCredits
|
|
|
167
219
|
end
|
|
168
220
|
end
|
|
169
221
|
|
|
170
|
-
# 2) Create
|
|
171
|
-
# Use current_period_start as the base time, falling back to
|
|
222
|
+
# 2) Create or reactivate Fulfillment record for subsequent awarding
|
|
223
|
+
# Use current_period_start as the base time, falling back to Time.current
|
|
172
224
|
period_start = if trial_ends_at && status == "trialing"
|
|
173
225
|
trial_ends_at
|
|
174
226
|
else
|
|
175
|
-
current_period_start ||
|
|
227
|
+
current_period_start || Time.current
|
|
176
228
|
end
|
|
177
229
|
|
|
178
230
|
# Ensure next_fulfillment_at is in the future
|
|
179
231
|
next_fulfillment_at = period_start + plan.parsed_fulfillment_period
|
|
180
232
|
next_fulfillment_at = Time.current + plan.parsed_fulfillment_period if next_fulfillment_at <= Time.current
|
|
181
233
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
234
|
+
if is_reactivation
|
|
235
|
+
# Reactivate the existing stopped/scheduled-to-stop fulfillment
|
|
236
|
+
# Merge metadata to preserve any custom keys while updating core fields
|
|
237
|
+
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
238
|
+
existing_reactivatable_fulfillment.update!(
|
|
239
|
+
credits_last_fulfillment: total_credits_awarded,
|
|
240
|
+
fulfillment_period: plan.fulfillment_period_display,
|
|
241
|
+
last_fulfilled_at: Time.current,
|
|
242
|
+
next_fulfillment_at: next_fulfillment_at,
|
|
243
|
+
stops_at: fulfillment_should_stop_at,
|
|
244
|
+
metadata: existing_reactivatable_fulfillment.metadata
|
|
245
|
+
.except("stopped_reason", "stopped_at", "pending_plan_change", "plan_change_at")
|
|
246
|
+
.merge(
|
|
247
|
+
"subscription_id" => id,
|
|
248
|
+
"plan" => processor_plan,
|
|
249
|
+
"reactivated_at" => Time.current
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
fulfillment = existing_reactivatable_fulfillment
|
|
253
|
+
|
|
254
|
+
Rails.logger.info "Reactivated fulfillment #{fulfillment.id} for subscription #{id}"
|
|
255
|
+
else
|
|
256
|
+
# Create new fulfillment
|
|
257
|
+
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
258
|
+
fulfillment = UsageCredits::Fulfillment.create!(
|
|
259
|
+
wallet: wallet,
|
|
260
|
+
source: self,
|
|
261
|
+
fulfillment_type: "subscription",
|
|
262
|
+
credits_last_fulfillment: total_credits_awarded,
|
|
263
|
+
fulfillment_period: plan.fulfillment_period_display,
|
|
264
|
+
last_fulfilled_at: Time.current,
|
|
265
|
+
next_fulfillment_at: next_fulfillment_at,
|
|
266
|
+
stops_at: fulfillment_should_stop_at, # Pre-emptively set when the fulfillment will stop, in case we miss a future event (like sub cancellation)
|
|
267
|
+
metadata: {
|
|
268
|
+
"subscription_id" => id,
|
|
269
|
+
"plan" => processor_plan,
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
Rails.logger.info "Initial fulfillment for subscription #{id} finished. Created fulfillment #{fulfillment.id}"
|
|
274
|
+
end
|
|
196
275
|
|
|
197
276
|
# Link created transactions to the fulfillment object for traceability
|
|
198
277
|
UsageCredits::Transaction.where(id: transaction_ids).update_all(fulfillment_id: fulfillment.id)
|
|
199
278
|
|
|
200
|
-
Rails.logger.info "Initial fulfillment for subscription #{id} finished. Created fulfillment #{fulfillment.id}"
|
|
201
279
|
rescue => e
|
|
202
280
|
Rails.logger.error "Failed to fulfill initial credits for subscription #{id}: #{e.message}"
|
|
203
281
|
raise ActiveRecord::Rollback
|
|
@@ -214,8 +292,13 @@ module UsageCredits
|
|
|
214
292
|
return unless fulfillment
|
|
215
293
|
|
|
216
294
|
ActiveRecord::Base.transaction do
|
|
295
|
+
# Check if there's a pending plan change to apply
|
|
296
|
+
if fulfillment.metadata["pending_plan_change"].present?
|
|
297
|
+
apply_pending_plan_change(fulfillment)
|
|
298
|
+
end
|
|
299
|
+
|
|
217
300
|
# Subscription renewed, we can set the new Fulfillment stops_at to the extended date
|
|
218
|
-
fulfillment.update!(stops_at:
|
|
301
|
+
fulfillment.update!(stops_at: fulfillment_should_stop_at)
|
|
219
302
|
Rails.logger.info "Fulfillment #{fulfillment.id} stops_at updated to #{fulfillment.stops_at}"
|
|
220
303
|
rescue => e
|
|
221
304
|
Rails.logger.error "Failed to extend fulfillment period for subscription #{id}: #{e.message}"
|
|
@@ -234,7 +317,7 @@ module UsageCredits
|
|
|
234
317
|
|
|
235
318
|
ActiveRecord::Base.transaction do
|
|
236
319
|
# Subscription cancelled, so stop awarding credits in the future
|
|
237
|
-
fulfillment.update!(stops_at:
|
|
320
|
+
fulfillment.update!(stops_at: fulfillment_should_stop_at)
|
|
238
321
|
Rails.logger.info "Fulfillment #{fulfillment.id} stops_at set to #{fulfillment.stops_at} due to cancellation"
|
|
239
322
|
rescue => e
|
|
240
323
|
Rails.logger.error "Failed to stop credit fulfillment for subscription #{id}: #{e.message}"
|
|
@@ -247,5 +330,265 @@ module UsageCredits
|
|
|
247
330
|
|
|
248
331
|
end
|
|
249
332
|
|
|
333
|
+
# Wrapper to check condition and call handle_plan_change
|
|
334
|
+
def handle_plan_change_wrapper
|
|
335
|
+
return unless plan_changed?
|
|
336
|
+
handle_plan_change
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Handle plan changes (upgrades/downgrades)
|
|
340
|
+
def handle_plan_change
|
|
341
|
+
return unless has_valid_wallet?
|
|
342
|
+
|
|
343
|
+
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
|
344
|
+
return unless fulfillment
|
|
345
|
+
|
|
346
|
+
# Debug logging to track plan changes and potential issues
|
|
347
|
+
Rails.logger.info "=" * 80
|
|
348
|
+
Rails.logger.info "[UsageCredits] Plan change detected for subscription #{id}"
|
|
349
|
+
Rails.logger.info " Processor plan changed: #{saved_change_to_processor_plan.inspect}"
|
|
350
|
+
Rails.logger.info " Subscription status: #{status}"
|
|
351
|
+
Rails.logger.info " Current period end: #{current_period_end}"
|
|
352
|
+
Rails.logger.info " Fulfillment metadata: #{fulfillment.metadata.inspect}"
|
|
353
|
+
Rails.logger.info " Fulfillment period: #{fulfillment.fulfillment_period}"
|
|
354
|
+
Rails.logger.info " Next fulfillment at: #{fulfillment.next_fulfillment_at}"
|
|
355
|
+
|
|
356
|
+
# Warn if current_period_end is nil for an active subscription - this is an edge case
|
|
357
|
+
# that could indicate incomplete data from the payment processor
|
|
358
|
+
if current_period_end.nil? && status == "active"
|
|
359
|
+
Rails.logger.warn "Subscription #{id} is active but current_period_end is nil - using Time.current as fallback for plan change scheduling"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Get the current active plan (what the user is ACTUALLY on right now)
|
|
363
|
+
# This is crucial for handling multiple plan changes in one billing period
|
|
364
|
+
current_plan_id = fulfillment.metadata["plan"]
|
|
365
|
+
new_plan_id = processor_plan
|
|
366
|
+
|
|
367
|
+
Rails.logger.info " Looking up current plan: #{current_plan_id}"
|
|
368
|
+
Rails.logger.info " Looking up new plan: #{new_plan_id}"
|
|
369
|
+
|
|
370
|
+
current_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(current_plan_id)
|
|
371
|
+
new_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(new_plan_id)
|
|
372
|
+
|
|
373
|
+
Rails.logger.info " Current plan found: #{current_plan&.name} (#{current_plan&.credits_per_period} credits)"
|
|
374
|
+
Rails.logger.info " New plan found: #{new_plan&.name} (#{new_plan&.credits_per_period} credits)"
|
|
375
|
+
|
|
376
|
+
# Handle downgrade to a non-credit plan: schedule fulfillment stop for end of period
|
|
377
|
+
if new_plan.nil? && current_plan.present?
|
|
378
|
+
handle_downgrade_to_non_credit_plan(fulfillment)
|
|
379
|
+
return
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
return unless new_plan # Neither current nor new plan provides credits - nothing to do
|
|
383
|
+
|
|
384
|
+
ActiveRecord::Base.transaction do
|
|
385
|
+
# FIRST: Check if returning to current plan (canceling a pending change)
|
|
386
|
+
# This must come first! Returning to current plan = no credits, just clear pending
|
|
387
|
+
# This matches Stripe's billing: no new charge means no new credits
|
|
388
|
+
if current_plan_id == new_plan_id
|
|
389
|
+
Rails.logger.info " Action: Returning to current plan (clearing pending change)"
|
|
390
|
+
clear_pending_plan_change(fulfillment)
|
|
391
|
+
return
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Now compare credits to determine upgrade vs downgrade
|
|
395
|
+
current_credits = current_plan&.credits_per_period || 0
|
|
396
|
+
new_credits = new_plan.credits_per_period
|
|
397
|
+
|
|
398
|
+
Rails.logger.info " Comparing credits: #{current_credits} → #{new_credits}"
|
|
399
|
+
|
|
400
|
+
if new_credits > current_credits
|
|
401
|
+
# UPGRADE: Grant new plan credits immediately
|
|
402
|
+
Rails.logger.info " Action: UPGRADE detected - awarding #{new_credits} credits immediately"
|
|
403
|
+
handle_plan_upgrade(new_plan, fulfillment)
|
|
404
|
+
elsif new_credits < current_credits
|
|
405
|
+
# DOWNGRADE: Schedule for end of period (overwrites any previous pending)
|
|
406
|
+
Rails.logger.info " Action: DOWNGRADE detected - scheduling for end of period"
|
|
407
|
+
handle_plan_downgrade(new_plan, fulfillment)
|
|
408
|
+
else
|
|
409
|
+
# Same credits amount, different plan - update metadata immediately
|
|
410
|
+
Rails.logger.info " Action: Same credits, different plan - updating metadata only"
|
|
411
|
+
update_fulfillment_plan_metadata(fulfillment, new_plan_id)
|
|
412
|
+
end
|
|
413
|
+
rescue => e
|
|
414
|
+
Rails.logger.error "Failed to handle plan change for subscription #{id}: #{e.message}"
|
|
415
|
+
Rails.logger.error e.backtrace.join("\n")
|
|
416
|
+
raise ActiveRecord::Rollback
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
Rails.logger.info " Plan change completed successfully"
|
|
420
|
+
Rails.logger.info "=" * 80
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def handle_plan_upgrade(new_plan, fulfillment)
|
|
424
|
+
wallet = customer.owner.credit_wallet
|
|
425
|
+
|
|
426
|
+
Rails.logger.info " [UPGRADE] Starting upgrade process"
|
|
427
|
+
Rails.logger.info " [UPGRADE] Wallet ID: #{wallet.id}, Current balance: #{wallet.balance}"
|
|
428
|
+
Rails.logger.info " [UPGRADE] Credits to award: #{new_plan.credits_per_period}"
|
|
429
|
+
Rails.logger.info " [UPGRADE] New plan period: #{new_plan.fulfillment_period_display}"
|
|
430
|
+
|
|
431
|
+
# Calculate expiration using shared helper (uses current_period_end for upgrades)
|
|
432
|
+
credits_expire_at = calculate_credit_expiration(new_plan, current_period_end)
|
|
433
|
+
|
|
434
|
+
Rails.logger.info " [UPGRADE] Credits expire at: #{credits_expire_at || 'never (rollover enabled)'}"
|
|
435
|
+
|
|
436
|
+
# Grant full new plan credits immediately
|
|
437
|
+
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
438
|
+
wallet.add_credits(
|
|
439
|
+
new_plan.credits_per_period,
|
|
440
|
+
category: "subscription_upgrade",
|
|
441
|
+
expires_at: credits_expire_at,
|
|
442
|
+
metadata: {
|
|
443
|
+
"subscription_id" => id,
|
|
444
|
+
"plan" => processor_plan,
|
|
445
|
+
"reason" => "plan_upgrade",
|
|
446
|
+
"fulfilled_at" => Time.current
|
|
447
|
+
}
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
Rails.logger.info " [UPGRADE] Credits awarded successfully"
|
|
451
|
+
Rails.logger.info " [UPGRADE] New balance: #{wallet.reload.balance}"
|
|
452
|
+
|
|
453
|
+
# Calculate next fulfillment time based on the NEW plan's period
|
|
454
|
+
# This ensures the fulfillment schedule matches the new plan's cadence
|
|
455
|
+
next_fulfillment_at = Time.current + new_plan.parsed_fulfillment_period
|
|
456
|
+
|
|
457
|
+
Rails.logger.info " [UPGRADE] Updating fulfillment record"
|
|
458
|
+
Rails.logger.info " [UPGRADE] Old fulfillment_period: #{fulfillment.fulfillment_period}"
|
|
459
|
+
Rails.logger.info " [UPGRADE] New fulfillment_period: #{new_plan.fulfillment_period_display}"
|
|
460
|
+
Rails.logger.info " [UPGRADE] Old next_fulfillment_at: #{fulfillment.next_fulfillment_at}"
|
|
461
|
+
Rails.logger.info " [UPGRADE] New next_fulfillment_at: #{next_fulfillment_at}"
|
|
462
|
+
|
|
463
|
+
# Update fulfillment with ALL new plan properties
|
|
464
|
+
# This includes the period display string and the next fulfillment time
|
|
465
|
+
# to ensure future fulfillments happen on the correct schedule
|
|
466
|
+
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
467
|
+
fulfillment.update!(
|
|
468
|
+
fulfillment_period: new_plan.fulfillment_period_display,
|
|
469
|
+
next_fulfillment_at: next_fulfillment_at,
|
|
470
|
+
metadata: fulfillment.metadata
|
|
471
|
+
.except("pending_plan_change", "plan_change_at")
|
|
472
|
+
.merge("plan" => processor_plan)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
Rails.logger.info " [UPGRADE] Fulfillment updated successfully"
|
|
476
|
+
Rails.logger.info "Subscription #{id} upgraded to #{processor_plan}, granted #{new_plan.credits_per_period} credits"
|
|
477
|
+
Rails.logger.info " Fulfillment period updated to: #{new_plan.fulfillment_period_display}"
|
|
478
|
+
Rails.logger.info " Next fulfillment scheduled for: #{next_fulfillment_at}"
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def handle_plan_downgrade(new_plan, fulfillment)
|
|
482
|
+
# Schedule the downgrade for end of current period
|
|
483
|
+
# User keeps current plan benefits until then
|
|
484
|
+
# Ensure schedule_time is never in the past (handles edge cases like stale data)
|
|
485
|
+
schedule_time = [current_period_end || Time.current, Time.current].max
|
|
486
|
+
|
|
487
|
+
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
488
|
+
fulfillment.update!(
|
|
489
|
+
metadata: fulfillment.metadata.merge(
|
|
490
|
+
"pending_plan_change" => processor_plan,
|
|
491
|
+
"plan_change_at" => schedule_time
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
Rails.logger.info "Subscription #{id} downgrade to #{processor_plan} scheduled for #{schedule_time}"
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def handle_downgrade_to_non_credit_plan(fulfillment)
|
|
499
|
+
# User is downgrading from a credit plan to a non-credit plan
|
|
500
|
+
# Schedule the fulfillment to stop at end of current period
|
|
501
|
+
# User keeps their existing credits (no clawback)
|
|
502
|
+
# Ensure schedule_time is never in the past
|
|
503
|
+
schedule_time = [current_period_end || Time.current, Time.current].max
|
|
504
|
+
|
|
505
|
+
ActiveRecord::Base.transaction do
|
|
506
|
+
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
507
|
+
fulfillment.update!(
|
|
508
|
+
stops_at: schedule_time,
|
|
509
|
+
metadata: fulfillment.metadata.merge(
|
|
510
|
+
"stopped_reason" => "downgrade_to_non_credit_plan",
|
|
511
|
+
"stopped_at" => schedule_time
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
Rails.logger.info "Subscription #{id} downgraded to non-credit plan #{processor_plan}, fulfillment will stop at #{schedule_time}"
|
|
516
|
+
rescue => e
|
|
517
|
+
Rails.logger.error "Failed to handle downgrade to non-credit plan for subscription #{id}: #{e.message}"
|
|
518
|
+
Rails.logger.error e.backtrace.join("\n")
|
|
519
|
+
raise ActiveRecord::Rollback
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def update_fulfillment_plan_metadata(fulfillment, new_plan_id)
|
|
524
|
+
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
525
|
+
fulfillment.update!(
|
|
526
|
+
metadata: fulfillment.metadata.merge("plan" => new_plan_id)
|
|
527
|
+
)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Clear any pending plan change metadata
|
|
531
|
+
# Used when user upgrades back to their current plan after scheduling a downgrade
|
|
532
|
+
def clear_pending_plan_change(fulfillment)
|
|
533
|
+
return unless fulfillment.metadata["pending_plan_change"].present?
|
|
534
|
+
|
|
535
|
+
fulfillment.update!(
|
|
536
|
+
metadata: fulfillment.metadata.except("pending_plan_change", "plan_change_at")
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
Rails.logger.info "Subscription #{id} pending plan change cleared (returned to current plan)"
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def apply_pending_plan_change(fulfillment)
|
|
543
|
+
pending_plan = fulfillment.metadata["pending_plan_change"]
|
|
544
|
+
|
|
545
|
+
# Validate that the pending plan still exists in configuration
|
|
546
|
+
# This handles the edge case where an admin removes a plan after a user scheduled a downgrade
|
|
547
|
+
unless UsageCredits.configuration.find_subscription_plan_by_processor_id(pending_plan)
|
|
548
|
+
Rails.logger.error "Cannot apply pending plan change for subscription #{id}: plan '#{pending_plan}' not found in configuration"
|
|
549
|
+
# Clear the invalid pending change to prevent repeated failures
|
|
550
|
+
fulfillment.update!(
|
|
551
|
+
metadata: fulfillment.metadata.except("pending_plan_change", "plan_change_at")
|
|
552
|
+
)
|
|
553
|
+
return
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Update to the new plan and clear the pending change
|
|
557
|
+
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
558
|
+
fulfillment.update!(
|
|
559
|
+
metadata: fulfillment.metadata
|
|
560
|
+
.except("pending_plan_change", "plan_change_at")
|
|
561
|
+
.merge("plan" => pending_plan)
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
Rails.logger.info "Applied pending plan change for subscription #{id}: now on #{pending_plan}"
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# =========================================
|
|
568
|
+
# Helper Methods
|
|
569
|
+
# =========================================
|
|
570
|
+
|
|
571
|
+
# Calculate credit expiration date for a given plan
|
|
572
|
+
# Handles the edge case where base_time might be in the past (e.g., paused subscription reactivated)
|
|
573
|
+
# by ensuring we never create credits that are already expired
|
|
574
|
+
def calculate_credit_expiration(plan, base_time = nil)
|
|
575
|
+
return nil if plan.rollover_enabled
|
|
576
|
+
|
|
577
|
+
# Use the provided base_time or fall back to current time
|
|
578
|
+
# Crucially: ensure we never use a time in the past, which would create already-expired credits
|
|
579
|
+
# This fixes the bug where a paused subscription reactivated would have past expiration dates
|
|
580
|
+
effective_base = [base_time || Time.current, Time.current].max
|
|
581
|
+
|
|
582
|
+
# Cap the grace period to the fulfillment period to prevent balance accumulation
|
|
583
|
+
# when fulfillment_period << grace_period (e.g., 15 seconds vs 5 minutes)
|
|
584
|
+
fulfillment_period = plan.parsed_fulfillment_period
|
|
585
|
+
effective_grace = [
|
|
586
|
+
UsageCredits.configuration.fulfillment_grace_period,
|
|
587
|
+
fulfillment_period
|
|
588
|
+
].min
|
|
589
|
+
|
|
590
|
+
effective_base + fulfillment_period + effective_grace
|
|
591
|
+
end
|
|
592
|
+
|
|
250
593
|
end
|
|
251
594
|
end
|
|
@@ -19,7 +19,7 @@ module UsageCredits
|
|
|
19
19
|
|
|
20
20
|
attr_writer :fulfillment_period
|
|
21
21
|
|
|
22
|
-
MIN_PERIOD = 1.day
|
|
22
|
+
MIN_PERIOD = 1.day # Deprecated: Use UsageCredits.configuration.min_fulfillment_period instead
|
|
23
23
|
|
|
24
24
|
def initialize(name)
|
|
25
25
|
@name = name
|
|
@@ -88,32 +88,134 @@ module UsageCredits
|
|
|
88
88
|
# Payment Processor Integration
|
|
89
89
|
# =========================================
|
|
90
90
|
|
|
91
|
-
# Set the processor-specific plan ID
|
|
91
|
+
# Set the processor-specific plan ID(s)
|
|
92
|
+
# Accepts either a single ID (String) or multiple period-specific IDs (Hash)
|
|
93
|
+
#
|
|
94
|
+
# @param processor [Symbol] The payment processor (e.g., :stripe)
|
|
95
|
+
# @param id [String, Hash] Single ID or hash of period => ID pairs
|
|
96
|
+
# @example Single ID (backward compatible)
|
|
97
|
+
# processor_plan(:stripe, "price_123")
|
|
98
|
+
# @example Multiple periods
|
|
99
|
+
# processor_plan(:stripe, { month: "price_m", year: "price_y" })
|
|
92
100
|
def processor_plan(processor, id)
|
|
101
|
+
if id.is_a?(Hash)
|
|
102
|
+
raise ArgumentError, "Period hash cannot be empty" if id.empty?
|
|
103
|
+
# Normalize all keys to symbols for consistent lookup
|
|
104
|
+
id = id.transform_keys(&:to_sym)
|
|
105
|
+
end
|
|
93
106
|
processor_plan_ids[processor.to_sym] = id
|
|
94
107
|
end
|
|
95
108
|
|
|
96
|
-
# Get the plan ID for a specific processor
|
|
97
|
-
|
|
98
|
-
|
|
109
|
+
# Get the plan ID(s) for a specific processor
|
|
110
|
+
# @param processor [Symbol] The payment processor
|
|
111
|
+
# @param period [Symbol, nil] Optional specific period to retrieve
|
|
112
|
+
# @return [String, Hash, nil] Single ID, hash of IDs, or nil
|
|
113
|
+
def plan_id_for(processor, period: nil)
|
|
114
|
+
ids = processor_plan_ids[processor.to_sym]
|
|
115
|
+
|
|
116
|
+
# If no period specified, return as-is (String or Hash)
|
|
117
|
+
return ids if period.nil?
|
|
118
|
+
|
|
119
|
+
# If ids is a Hash, return the specific period
|
|
120
|
+
return ids[period.to_sym] if ids.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
# If ids is a String and they asked for a period, return nil (not multi-period)
|
|
123
|
+
nil
|
|
99
124
|
end
|
|
100
125
|
|
|
101
|
-
# Shorthand for Stripe price ID
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
126
|
+
# Shorthand for Stripe price ID(s)
|
|
127
|
+
# Supports both single price and multi-period prices
|
|
128
|
+
#
|
|
129
|
+
# @overload stripe_price
|
|
130
|
+
# Get all Stripe price IDs
|
|
131
|
+
# @return [String, Hash] Single price ID or hash of period => price_id
|
|
132
|
+
# @overload stripe_price(id)
|
|
133
|
+
# Set a single Stripe price ID (backward compatible)
|
|
134
|
+
# @param id [String] The Stripe price ID
|
|
135
|
+
# @overload stripe_price(prices)
|
|
136
|
+
# Set multiple period-specific Stripe price IDs
|
|
137
|
+
# @param prices [Hash] Hash of period => price_id (e.g., { month: "price_m", year: "price_y" })
|
|
138
|
+
# @overload stripe_price(period)
|
|
139
|
+
# Get Stripe price ID for a specific period
|
|
140
|
+
# @param period [Symbol] The billing period (e.g., :month, :year)
|
|
141
|
+
# @return [String, nil] Price ID for that period
|
|
142
|
+
#
|
|
143
|
+
# @example Get all prices
|
|
144
|
+
# plan.stripe_price # => { month: "price_m", year: "price_y" }
|
|
145
|
+
# @example Get specific period
|
|
146
|
+
# plan.stripe_price(:month) # => "price_m"
|
|
147
|
+
# @example Set single price (backward compatible)
|
|
148
|
+
# stripe_price "price_123"
|
|
149
|
+
# @example Set multiple periods
|
|
150
|
+
# stripe_price month: "price_m", year: "price_y"
|
|
151
|
+
def stripe_price(id_or_period = nil)
|
|
152
|
+
if id_or_period.nil?
|
|
153
|
+
# Getter: return all prices
|
|
154
|
+
plan_id_for(:stripe)
|
|
155
|
+
elsif id_or_period.is_a?(Hash)
|
|
156
|
+
# Setter: hash of period => price_id
|
|
157
|
+
processor_plan(:stripe, id_or_period)
|
|
158
|
+
elsif id_or_period.is_a?(Symbol)
|
|
159
|
+
# Getter: specific period
|
|
160
|
+
plan_id_for(:stripe, period: id_or_period)
|
|
105
161
|
else
|
|
106
|
-
|
|
162
|
+
# Setter: single price ID (backward compatible)
|
|
163
|
+
processor_plan(:stripe, id_or_period)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Get all Stripe price IDs as a hash (always returns hash format)
|
|
168
|
+
# @return [Hash] Hash of period => price_id, or { default: price_id } for single-price plans
|
|
169
|
+
def stripe_prices
|
|
170
|
+
ids = plan_id_for(:stripe)
|
|
171
|
+
return {} if ids.nil?
|
|
172
|
+
return ids if ids.is_a?(Hash)
|
|
173
|
+
{ default: ids } # Wrap single ID in hash for consistency
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Check if this plan matches a given processor price ID
|
|
177
|
+
# Works with both single-price and multi-period plans
|
|
178
|
+
# @param processor_id [String] The price ID to match
|
|
179
|
+
# @return [Boolean] True if this plan includes the given price ID
|
|
180
|
+
def matches_processor_id?(processor_id)
|
|
181
|
+
processor_plan_ids.values.any? do |ids|
|
|
182
|
+
if ids.is_a?(Hash)
|
|
183
|
+
ids.values.include?(processor_id)
|
|
184
|
+
else
|
|
185
|
+
ids == processor_id
|
|
186
|
+
end
|
|
107
187
|
end
|
|
108
188
|
end
|
|
109
189
|
|
|
110
190
|
# Create a checkout session for this subscription plan
|
|
111
|
-
|
|
191
|
+
# @param user [Object] The user creating the checkout session
|
|
192
|
+
# @param success_url [String] URL to redirect after successful checkout
|
|
193
|
+
# @param cancel_url [String] URL to redirect if checkout is cancelled
|
|
194
|
+
# @param processor [Symbol] Payment processor to use (default: :stripe)
|
|
195
|
+
# @param period [Symbol, nil] Billing period for multi-period plans (e.g., :month, :year)
|
|
196
|
+
#
|
|
197
|
+
# @example Single-price plan (backward compatible)
|
|
198
|
+
# plan.create_checkout_session(user, success_url: "/success", cancel_url: "/cancel")
|
|
199
|
+
#
|
|
200
|
+
# @example Multi-period plan (must specify period)
|
|
201
|
+
# plan.create_checkout_session(user, success_url: "/success", cancel_url: "/cancel", period: :month)
|
|
202
|
+
# plan.create_checkout_session(user, success_url: "/success", cancel_url: "/cancel", period: :year)
|
|
203
|
+
def create_checkout_session(user, success_url:, cancel_url:, processor: :stripe, period: nil)
|
|
112
204
|
raise ArgumentError, "User must respond to payment_processor" unless user.respond_to?(:payment_processor)
|
|
113
205
|
raise ArgumentError, "No fulfillment period configured for plan: #{name}" unless fulfillment_period
|
|
114
206
|
|
|
115
|
-
|
|
116
|
-
raise ArgumentError, "No #{processor.to_s.titleize} plan ID configured for plan: #{name}" unless
|
|
207
|
+
plan_ids = plan_id_for(processor)
|
|
208
|
+
raise ArgumentError, "No #{processor.to_s.titleize} plan ID configured for plan: #{name}" unless plan_ids
|
|
209
|
+
|
|
210
|
+
# Determine which price ID to use
|
|
211
|
+
plan_id = if plan_ids.is_a?(Hash)
|
|
212
|
+
# Multi-period plan: period is required
|
|
213
|
+
raise ArgumentError, "This plan has multiple billing periods (#{plan_ids.keys.join(', ')}). Please specify period: parameter (e.g., period: :month)" if period.nil?
|
|
214
|
+
plan_ids[period.to_sym] || raise(ArgumentError, "Period #{period.inspect} not found. Available periods: #{plan_ids.keys.inspect}")
|
|
215
|
+
else
|
|
216
|
+
# Single-price plan: use the ID directly
|
|
217
|
+
plan_ids
|
|
218
|
+
end
|
|
117
219
|
|
|
118
220
|
case processor
|
|
119
221
|
when :stripe
|
|
@@ -157,7 +259,6 @@ module UsageCredits
|
|
|
157
259
|
}],
|
|
158
260
|
success_url: success_url,
|
|
159
261
|
cancel_url: cancel_url,
|
|
160
|
-
payment_intent_data: { metadata: base_metadata },
|
|
161
262
|
subscription_data: { metadata: base_metadata }
|
|
162
263
|
)
|
|
163
264
|
end
|