Deferred Subscription Activation
ADR 001: Deferred Subscription Activation
Date: 2026-04
Status: Accepted
Context: Billing system needs to prevent granting user access before payment confirmation.
Problem
When a user initiates a Primer checkout for a subscription:
- We must create a subscription in Chargebee
- But we cannot grant access until payment clears
- How do we prevent premature access while allowing idempotent retry?
Decision
Create subscriptions with start_date = now + 10 years, keeping them in future status until payment is recorded.
Implementation
// 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)Rationale
Why This Approach?
- Prevents premature access: Until
start_dateis updated, subscription remains infuturestatus → user has no rights - Detectable pending state: Cleanup worker identifies expired checkouts as
future+start > 1 year - Idempotency-friendly: If payment confirmation is retried, we can verify subscription state before re-activating
- Chargebee semantics: The
0value is Chargebee’s documented way to mean “start immediately”
Alternatives Considered
| 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 |
Consequences
Positive
- Simple state machine: Clear
future→active→non_renewing→cancelledtransitions - Cleanup straightforward: Identify orphaned checkouts in one query
- Idempotency safe: Locking + state check prevents double-activation
Negative
- Chargebee term_end quirk: Using
0as “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)
Testing
// Test: Subscription starts in futuresub := 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 activeerr := 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)))Related ADRs
- 002: Polling Over Webhooks — Ensures payment is detected regardless of webhook reliability
- 003: Primer Payment Gateway — Context for Primer checkout flow
- 005: Distributed Locks for Payments — Protects activation from race conditions