返回 Skill 列表
extension
分类: 开发与工程无需 API Key

subscription-patterns

适用于Stripe + Convex/database后端的订阅生命周期模式。用于:试用转付费流程、订阅状态管理、Webhook处理、访问控制逻辑、取消/重新订阅特殊情况。

person作者: jakexiaohubgithub

Subscription Lifecycle Patterns

Best practices for managing subscription states, trials, and access control with Stripe.

Core Principle

Stripe is the source of truth for billing. Your database caches state for access decisions.


Trial-to-Paid Flow

The Right Way: Use Stripe's trial_end

When a user subscribes during their trial, honor remaining days:

// In checkout session creation
const TRIAL_DURATION_MS = 14 * 24 * 60 * 60 * 1000;

// Calculate remaining trial
const trialEndMs = user?.trialEndsAt
  ?? (user?._creationTime ? user._creationTime + TRIAL_DURATION_MS : null);

const now = Date.now();
const hasRemainingTrial = trialEndMs && trialEndMs > now;
const trialEndSeconds = hasRemainingTrial ? Math.floor(trialEndMs / 1000) : undefined;

// Pass to Stripe - it handles billing delay
const session = await stripe.checkout.sessions.create({
  // ...
  subscription_data: {
    metadata: { userId },
    ...(trialEndSeconds && { trial_end: trialEndSeconds }),
  },
});

Benefits:

  • Stripe delays first charge until trial_end
  • User sees "trial ends on X" in Stripe customer portal
  • No manual billing logic needed

Prevent Zombie Trials

Problem: If trial data persists after subscription, canceled users may regain access.

Solution: Clear trial when subscription activates:

// In webhook handler / updateFromStripe mutation
await db.patch(user._id, {
  subscriptionStatus: status,
  // Clear trial to prevent zombie access after cancel
  ...(status === "active" && { trialEndsAt: 0 }),
});

Access Control Priority

Check states in this order (first match wins):

function hasAccess(user): boolean {
  // 1. Active subscription - always grants access
  if (user.subscriptionStatus === "active") return true;

  // 2. Canceled but in paid period - access through period end
  if (user.subscriptionStatus === "canceled" &&
      user.currentPeriodEnd &&
      Date.now() < user.currentPeriodEnd) {
    return true;
  }

  // 3. Past due with grace period
  if (user.subscriptionStatus === "past_due" &&
      user.currentPeriodEnd &&
      Date.now() < user.currentPeriodEnd) {
    return true;
  }

  // 4. Locked states - explicitly deny (before trial check)
  const lockedStates = ["incomplete", "unpaid", "expired"];
  if (lockedStates.includes(user.subscriptionStatus)) {
    return false;
  }

  // 5. Trial active - fallback for non-subscribers
  if (user.trialEndsAt && Date.now() < user.trialEndsAt) {
    return true;
  }

  return false;
}

Key insight: Locked states block before trial check. This prevents edge cases where trial data could grant access.


Edge Cases & Handling

User Cancels During Trial

  • subscriptionStatus = "canceled"
  • trialEndsAt already cleared (was set to 0 when sub activated)
  • Access continues until currentPeriodEnd (from Stripe trial_end)
  • No zombie trial risk

User Resubscribes After Cancel

  • New checkout creates new subscription
  • Fresh trial_end calculation (likely 0 - no trial remaining)
  • Billing starts immediately

User Never Had Trial (Direct Subscribe)

  • No trialEndsAt or _creationTime for trial calc
  • trial_end not passed to Stripe
  • Billing starts immediately (correct)

Webhook Arrives Out of Order

  • Use eventTimestamp comparison
  • Reject events older than last processed
  • Use eventId for exact deduplication

Webhook Event Handling

Essential Events

| Event | Action | |-------|--------| | checkout.session.completed | Link customer, initial status | | customer.subscription.created | Set status, period end | | customer.subscription.updated | Update status, period end | | customer.subscription.deleted | Set status to canceled/expired | | invoice.payment_succeeded | Update period end | | invoice.payment_failed | Set status to past_due |

Idempotency Pattern

// Check for duplicate event
if (user.lastStripeEventId === eventId) {
  return { success: false, reason: "duplicate_event" };
}

// Check for stale event
if (user.lastStripeEventTimestamp &&
    eventTimestamp < user.lastStripeEventTimestamp) {
  return { success: false, reason: "stale_event" };
}

// Process and record
await db.patch(user._id, {
  // ... updates
  lastStripeEventId: eventId,
  lastStripeEventTimestamp: eventTimestamp,
});

Testing Checklist

Trial Flow

  • [ ] New user gets 14-day trial
  • [ ] Trial countdown displays correctly
  • [ ] Access denied after trial expires (hard cutoff)

Subscribe During Trial

  • [ ] Remaining trial days passed to Stripe
  • [ ] Stripe subscription shows trial_end
  • [ ] No charge until trial_end
  • [ ] trialEndsAt cleared in database

Cancel Flow

  • [ ] Access continues until currentPeriodEnd
  • [ ] No zombie trial access after period ends
  • [ ] Resubscribe starts billing immediately

Webhook Handling

  • [ ] Out-of-order events handled correctly
  • [ ] Duplicate events rejected
  • [ ] Fallback works if customer not linked

Common Pitfalls

1. Lazy Trial Calculation Gone Wrong

Problem: Calculating trial from _creationTime without clearing it. Fix: Always clear trialEndsAt when subscription activates.

2. Checking Trial Before Locked States

Problem: User with status=expired might have valid trial dates. Fix: Check locked states before trial in access logic.

3. Missing currentPeriodEnd

Problem: Some webhooks don't include period end. Fix: Always set it from invoice.payment_succeeded as backup.

4. Not Using Stripe's trial_end

Problem: Custom trial logic that diverges from Stripe. Fix: Let Stripe manage trial via trial_end parameter.


Related Skills

  • billing-security - Security patterns for payment integrations
  • stripe-health - Webhook health diagnostics
  • reconciliation-patterns - Syncing external service state