Skip to content

Deferred Subscription Activation

Date: 2026-04
Status: Accepted
Context: Billing system needs to prevent granting user access before payment confirmation.

When a user initiates a Primer checkout for a subscription:

  1. We must create a subscription in Chargebee
  2. But we cannot grant access until payment clears
  3. How do we prevent premature access while allowing idempotent retry?

Create subscriptions with start_date = now + 10 years, keeping them in future status until payment is recorded.

// Subscription creation (primer.go)
params := &subscription.CreateWithItemsRequestParams{
StartDate: time.Now().AddDate(10, 0, 0), // Far future
ItemId: itemPriceID,
}
result := subscriptionAction.CreateWithItems(customerID, params).Request()
// → Chargebee status = "future" (not yet started)
// Payment recording (payments/processor.go)
// When Primer webhook confirms payment:
params := &subscription.ChangeTermEndRequestParams{
StartDate: 0, // Chargebee interprets 0 as "now"
}
subscription.ChangeTermEnd(subscriptionID, params).Request()
// → Chargebee status = "active" (immediately started)
  1. Prevents premature access: Until start_date is updated, subscription remains in future status → user has no rights
  2. Detectable pending state: Cleanup worker identifies expired checkouts as future + start > 1 year
  3. Idempotency-friendly: If payment confirmation is retried, we can verify subscription state before re-activating
  4. Chargebee semantics: The 0 value is Chargebee’s documented way to mean “start immediately”
Option Tradeoffs
Activate then deactivate if payment fails Requires subscription reversal; messy state
Use custom pending_checkout status Requires custom Chargebee fields; not portable
Use scheduled change at payment time Complex; harder to retry idempotently
  • Simple state machine: Clear future → active → non_renewing → cancelled transitions
  • Cleanup straightforward: Identify orphaned checkouts in one query
  • Idempotency safe: Locking + state check prevents double-activation
  • Chargebee term_end quirk: Using 0 as “now” is non-obvious; requires documentation
  • 10-year trick is magical: New developers need explanation; commented WHY, not just HOW
  • Renewal timing edge case: If user’s subscription is created but never activated, renewal engine ignores it (OK by design)
// Test: Subscription starts in future
sub := createPendingSubscription(itemPriceID)
require.Equal(t, domain.SubscriptionStatusFuture, sub.Status)
require.True(t, sub.StartDate.After(time.Now().AddDate(9, 0, 0)))
// Test: Activation moves to active
err := processor.ActivateSubscription(ctx, sub.ID)
require.NoError(t, err)
activated := getSubscription(sub.ID)
require.Equal(t, domain.SubscriptionStatusActive, activated.Status)
require.True(t, activated.StartDate.Before(time.Now().Add(1*time.Hour)))