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.
@@ -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
- @credit_subscription_plan ||= UsageCredits.configuration.find_subscription_plan_by_processor_id(processor_plan)
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 fullfillment_should_stop_at
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.exists?(source: self)
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
- # We'll skip if we already have a fulfillment record
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
- # Using the configured grace period
107
- credits_expire_at = !plan.rollover_enabled ? (created_at + plan.parsed_fulfillment_period + UsageCredits.configuration.fulfillment_grace_period) : nil
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 a Fulfillment record for subsequent awarding
171
- # Use current_period_start as the base time, falling back to created_at
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 || created_at
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
- fulfillment = UsageCredits::Fulfillment.create!(
183
- wallet: wallet,
184
- source: self,
185
- fulfillment_type: "subscription",
186
- credits_last_fulfillment: total_credits_awarded,
187
- fulfillment_period: plan.fulfillment_period_display,
188
- last_fulfilled_at: Time.now,
189
- next_fulfillment_at: next_fulfillment_at,
190
- stops_at: fullfillment_should_stop_at, # Pre-emptively set when the fulfillment will stop, in case we miss a future event (like sub cancellation)
191
- metadata: {
192
- subscription_id: id,
193
- plan: processor_plan,
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: fullfillment_should_stop_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: fullfillment_should_stop_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
- def plan_id_for(processor)
98
- processor_plan_ids[processor.to_sym]
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
- def stripe_price(id = nil)
103
- if id.nil?
104
- plan_id_for(:stripe) # getter
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
- processor_plan(:stripe, id) # setter
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
- def create_checkout_session(user, success_url:, cancel_url:, processor: :stripe)
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
- plan_id = plan_id_for(processor)
116
- raise ArgumentError, "No #{processor.to_s.titleize} plan ID configured for plan: #{name}" unless plan_id
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