---
name: subscription-management
description: Manage Stripe subscription lifecycle including creation, upgrades, downgrades, cancellations, and billing workflows. Use when implementing subscription features, handling plan changes, processing webhooks, or managing recurring billing for Mawidi's B2B SaaS clinic customers.
version: 1.0.0
tags: [stripe, subscriptions, billing, webhooks, payments, saas]
allowed-tools: [Read, Write, Edit, Bash, Grep]
related-skills:
  [api-development, error-handling, email-composer, database-migration]
last-updated: 2025-12-09
---

# Subscription Management

Comprehensive patterns for managing Stripe subscriptions in Mawidi's B2B SaaS platform.

**Token Savings**: ~12,000 tokens per subscription implementation
**Stack**: Stripe API, Next.js API routes, Prisma + Supabase dual database

## Overview

This skill provides production-ready patterns for:

- Creating subscriptions from checkout sessions
- Handling subscription webhooks
- Upgrading/downgrading plans with proration
- Canceling and reactivating subscriptions
- Managing payment failures and recovery
- Syncing subscription state to dual databases

## When to Use This Skill

Use this skill when:

- Implementing new subscription flows (annual/monthly plans)
- Adding upgrade/downgrade functionality
- Processing Stripe webhook events
- Handling failed payments and dunning
- Building billing dashboard features
- Syncing subscription data between Prisma and Supabase

## Quick Start Examples

### Example 1: Create Subscription from Checkout

**Scenario**: User completes checkout on pricing page

```typescript
// app/api/stripe/webhook/route.ts
import { stripe } from "@/lib/stripe";
import { saveToBothDatabases } from "@/lib/dual-database";

export async function POST(request: Request) {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature")!;

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (err) {
    return Response.json({ error: "Invalid signature" }, { status: 400 });
  }

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;

    // Create subscription record
    await saveToBothDatabases("subscription", {
      userId: session.client_reference_id!,
      stripeSubscriptionId: session.subscription as string,
      stripeCustomerId: session.customer as string,
      status: "active",
      planId: session.metadata?.planId,
      currentPeriodEnd: new Date(session.expires_at! * 1000),
    });

    // Send confirmation email
    await sendSubscriptionConfirmationEmail(session.customer_email!);
  }

  return Response.json({ received: true });
}
```

### Example 2: Handle Subscription Update (Upgrade/Downgrade)

**Scenario**: User upgrades from monthly to annual plan

```typescript
// app/api/subscriptions/change-plan/route.ts
import { getServerSession } from "next-auth";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";

export async function POST(request: Request) {
  const session = await getServerSession();
  if (!session?.user?.email) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { newPriceId } = await request.json();

  // Get current subscription
  const subscription = await db.subscription.findFirst({
    where: { userId: session.user.id, status: "active" },
  });

  if (!subscription) {
    return Response.json({ error: "No active subscription" }, { status: 404 });
  }

  // Update in Stripe with proration
  const updatedSubscription = await stripe.subscriptions.update(
    subscription.stripeSubscriptionId,
    {
      items: [
        {
          id: subscription.stripeItemId!,
          price: newPriceId,
        },
      ],
      proration_behavior: "create_prorations", // Pro-rate immediately
    },
  );

  // Sync to dual database (webhook will handle final state)
  await saveToBothDatabases("subscription", {
    where: { id: subscription.id },
    data: {
      planId: newPriceId,
      status: updatedSubscription.status,
    },
  });

  return Response.json({ success: true, subscription: updatedSubscription });
}
```

### Example 3: Cancel Subscription with Retention

**Scenario**: User cancels but keeps access until period end

```typescript
// app/api/subscriptions/cancel/route.ts
export async function POST(request: Request) {
  const session = await getServerSession();
  if (!session?.user?.email) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { reason, feedback } = await request.json();

  const subscription = await db.subscription.findFirst({
    where: { userId: session.user.id, status: "active" },
  });

  if (!subscription) {
    return Response.json({ error: "No active subscription" }, { status: 404 });
  }

  // Cancel at period end (retain access until then)
  const canceledSubscription = await stripe.subscriptions.update(
    subscription.stripeSubscriptionId,
    {
      cancel_at_period_end: true,
      metadata: { cancellation_reason: reason },
    },
  );

  // Log cancellation for analytics
  await saveToBothDatabases("audit_log", {
    userId: session.user.id,
    action: "subscription_canceled",
    metadata: {
      reason,
      feedback,
      periodEnd: canceledSubscription.current_period_end,
    },
  });

  // Send cancellation confirmation email
  await sendCancellationEmail(session.user.email, {
    accessUntil: new Date(canceledSubscription.current_period_end * 1000),
  });

  return Response.json({
    success: true,
    accessUntil: canceledSubscription.current_period_end,
  });
}
```

## Subscription Lifecycle Patterns

### 1. Create Subscription

**Trigger**: `checkout.session.completed` webhook

```typescript
// Handle in webhook route
case 'checkout.session.completed': {
  const session = event.data.object;

  await saveToBothDatabases('subscription', {
    userId: session.client_reference_id!,
    stripeSubscriptionId: session.subscription as string,
    stripeCustomerId: session.customer as string,
    stripeItemId: session.line_items?.data[0]?.id,
    status: 'active',
    planId: session.metadata?.planId,
    billingCycle: session.metadata?.billingCycle || 'monthly',
    currentPeriodStart: new Date(session.created * 1000),
    currentPeriodEnd: new Date(session.expires_at! * 1000),
  });

  // Send welcome email
  await sendWelcomeEmail(session.customer_email!, {
    planName: session.metadata?.planName,
    billingCycle: session.metadata?.billingCycle
  });

  break;
}
```

### 2. Update Subscription Status

**Trigger**: `customer.subscription.updated` webhook

```typescript
case 'customer.subscription.updated': {
  const subscription = event.data.object;

  await saveToBothDatabases('subscription', {
    where: { stripeSubscriptionId: subscription.id },
    data: {
      status: subscription.status,
      currentPeriodStart: new Date(subscription.current_period_start * 1000),
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    }
  });

  break;
}
```

### 3. Handle Payment Failure

**Trigger**: `invoice.payment_failed` webhook

```typescript
case 'invoice.payment_failed': {
  const invoice = event.data.object;

  // Mark subscription as past_due
  await saveToBothDatabases('subscription', {
    where: { stripeSubscriptionId: invoice.subscription as string },
    data: { status: 'past_due' }
  });

  // Send payment failure notification
  await sendPaymentFailedEmail(invoice.customer_email!, {
    amount: invoice.amount_due,
    dueDate: new Date(invoice.due_date! * 1000),
    invoiceUrl: invoice.hosted_invoice_url
  });

  break;
}
```

### 4. Cancel at Period End

```typescript
// User-initiated cancellation
const subscription = await stripe.subscriptions.update(subscriptionId, {
  cancel_at_period_end: true,
  metadata: {
    cancellation_reason: reason,
    canceled_by: userId,
    canceled_at: new Date().toISOString(),
  },
});

// Update database
await saveToBothDatabases("subscription", {
  where: { stripeSubscriptionId: subscriptionId },
  data: {
    cancelAtPeriodEnd: true,
    cancelReason: reason,
  },
});
```

### 5. Immediate Cancellation

```typescript
// Cancel immediately (no refund for remaining period)
const subscription = await stripe.subscriptions.cancel(subscriptionId, {
  prorate: false, // No refund
  invoice_now: false,
});

await saveToBothDatabases("subscription", {
  where: { stripeSubscriptionId: subscriptionId },
  data: {
    status: "canceled",
    canceledAt: new Date(),
  },
});
```

### 6. Reactivate Canceled Subscription

```typescript
// Remove cancel_at_period_end flag
const subscription = await stripe.subscriptions.update(subscriptionId, {
  cancel_at_period_end: false,
});

await saveToBothDatabases("subscription", {
  where: { stripeSubscriptionId: subscriptionId },
  data: {
    cancelAtPeriodEnd: false,
    status: "active",
  },
});

await sendReactivationEmail(user.email);
```

## Plan Change Patterns

### Upgrade with Immediate Proration

```typescript
// Monthly → Annual upgrade
const subscription = await stripe.subscriptions.update(subscriptionId, {
  items: [
    {
      id: itemId,
      price: ANNUAL_PRICE_ID,
    },
  ],
  proration_behavior: "create_prorations", // Charge prorated amount now
  billing_cycle_anchor: "now", // Reset billing cycle
});

// Calculate proration credit
const upcomingInvoice = await stripe.invoices.retrieveUpcoming({
  customer: customerId,
  subscription: subscriptionId,
});

return {
  proratedAmount: upcomingInvoice.amount_due,
  nextBillingDate: new Date(subscription.current_period_end * 1000),
  annualSavings: calculateAnnualSavings(MONTHLY_PRICE, ANNUAL_PRICE),
};
```

### Downgrade at Period End

```typescript
// Annual → Monthly downgrade (at renewal)
const subscription = await stripe.subscriptions.update(subscriptionId, {
  items: [
    {
      id: itemId,
      price: MONTHLY_PRICE_ID,
    },
  ],
  proration_behavior: "none", // Don't charge/refund until period end
  billing_cycle_anchor: "unchanged",
});

await saveToBothDatabases("subscription", {
  where: { stripeSubscriptionId: subscriptionId },
  data: {
    pendingPlanChange: MONTHLY_PRICE_ID,
    planChangeEffectiveDate: new Date(subscription.current_period_end * 1000),
  },
});
```

## Webhook Event Handling

### Required Webhook Events

**Essential events to handle:**

```typescript
const REQUIRED_EVENTS = [
  "checkout.session.completed", // New subscription created
  "customer.subscription.updated", // Plan changed, status changed
  "customer.subscription.deleted", // Subscription canceled
  "invoice.payment_succeeded", // Successful payment
  "invoice.payment_failed", // Failed payment
  "customer.subscription.trial_will_end", // Trial ending soon
  "invoice.upcoming", // Invoice coming in 3 days
] as const;
```

### Webhook Handler Structure

```typescript
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";

export async function POST(request: Request) {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature")!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (err: any) {
    console.error("Webhook signature verification failed:", err.message);
    return Response.json({ error: "Invalid signature" }, { status: 400 });
  }

  // Use switch for type-safe event handling
  switch (event.type) {
    case "checkout.session.completed":
      await handleCheckoutCompleted(event.data.object);
      break;
    case "customer.subscription.updated":
      await handleSubscriptionUpdated(event.data.object);
      break;
    case "customer.subscription.deleted":
      await handleSubscriptionDeleted(event.data.object);
      break;
    case "invoice.payment_failed":
      await handlePaymentFailed(event.data.object);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  return Response.json({ received: true });
}
```

## Database Schema

### Subscription Model

```prisma
model Subscription {
  id                    String   @id @default(cuid())
  userId                String
  user                  User     @relation(fields: [userId], references: [id])

  // Stripe IDs
  stripeSubscriptionId  String   @unique
  stripeCustomerId      String
  stripeItemId          String?

  // Plan details
  planId                String
  billingCycle          String   // 'monthly' | 'annual'
  status                String   // 'active' | 'past_due' | 'canceled' | 'incomplete'

  // Billing periods
  currentPeriodStart    DateTime
  currentPeriodEnd      DateTime

  // Cancellation
  cancelAtPeriodEnd     Boolean  @default(false)
  canceledAt            DateTime?
  cancelReason          String?

  // Plan changes
  pendingPlanChange     String?
  planChangeEffectiveDate DateTime?

  // Timestamps
  createdAt             DateTime @default(now())
  updatedAt             DateTime @updatedAt

  @@index([userId])
  @@index([status])
}
```

### Supabase Equivalent

```sql
CREATE TABLE subscriptions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  stripe_subscription_id TEXT UNIQUE NOT NULL,
  stripe_customer_id TEXT NOT NULL,
  stripe_item_id TEXT,
  plan_id TEXT NOT NULL,
  billing_cycle TEXT NOT NULL,
  status TEXT NOT NULL,
  current_period_start TIMESTAMP NOT NULL,
  current_period_end TIMESTAMP NOT NULL,
  cancel_at_period_end BOOLEAN DEFAULT false,
  canceled_at TIMESTAMP,
  cancel_reason TEXT,
  pending_plan_change TEXT,
  plan_change_effective_date TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);
```

## Error Handling Patterns

### Handle Stripe API Errors

```typescript
try {
  const subscription = await stripe.subscriptions.update(subscriptionId, {
    items: [{ id: itemId, price: newPriceId }],
  });
} catch (error) {
  if (error instanceof Stripe.errors.StripeError) {
    // Handle specific Stripe errors
    switch (error.type) {
      case "StripeCardError":
        return Response.json(
          {
            error: "Payment failed. Please update your payment method.",
          },
          { status: 402 },
        );

      case "StripeInvalidRequestError":
        console.error("Invalid Stripe request:", error.message);
        return Response.json(
          {
            error: "Invalid request. Please contact support.",
          },
          { status: 400 },
        );

      case "StripeRateLimitError":
        return Response.json(
          {
            error: "Too many requests. Please try again later.",
          },
          { status: 429 },
        );

      default:
        console.error("Stripe error:", error);
        return Response.json(
          {
            error: "Payment processing error. Please try again.",
          },
          { status: 500 },
        );
    }
  }
  throw error;
}
```

### Handle Webhook Idempotency

```typescript
// Prevent duplicate webhook processing
const processedEvent = await db.webhookEvent.findUnique({
  where: { stripeEventId: event.id },
});

if (processedEvent) {
  console.log(`Event ${event.id} already processed`);
  return Response.json({ received: true }); // Return 200, don't reprocess
}

// Process event...

// Mark as processed
await db.webhookEvent.create({
  data: {
    stripeEventId: event.id,
    eventType: event.type,
    processedAt: new Date(),
  },
});
```

## Testing Patterns

### Test Subscription Creation

```typescript
// __tests__/api/subscriptions/create.test.ts
import { POST } from "@/app/api/stripe/webhook/route";

describe("POST /api/stripe/webhook - checkout.session.completed", () => {
  it("creates subscription record in dual databases", async () => {
    const mockSession = {
      id: "cs_test_123",
      object: "checkout.session",
      client_reference_id: "user_123",
      customer: "cus_test",
      subscription: "sub_test",
      customer_email: "test@example.com",
      metadata: { planId: "price_annual", planName: "Annual Plan" },
      expires_at: Math.floor(Date.now() / 1000) + 31536000, // 1 year
    };

    const event = {
      id: "evt_test",
      type: "checkout.session.completed",
      data: { object: mockSession },
    };

    const request = new Request("http://localhost/api/stripe/webhook", {
      method: "POST",
      body: JSON.stringify(event),
      headers: { "stripe-signature": "test_sig" },
    });

    // Mock Stripe webhook verification
    jest.spyOn(stripe.webhooks, "constructEvent").mockReturnValue(event);

    const response = await POST(request);
    expect(response.status).toBe(200);

    // Verify subscription created in Prisma
    const subscription = await db.subscription.findFirst({
      where: { userId: "user_123" },
    });
    expect(subscription).toBeTruthy();
    expect(subscription?.status).toBe("active");

    // Verify subscription created in Supabase
    const supabaseRecord = await supabase
      .from("subscriptions")
      .select("*")
      .eq("user_id", "user_123")
      .single();
    expect(supabaseRecord.data).toBeTruthy();
  });
});
```

## Subscription Workflow Checklist

### Creating New Subscription

- [ ] Checkout session includes `client_reference_id` (userId)
- [ ] Checkout session includes metadata (planId, planName, billingCycle)
- [ ] Webhook handler verifies signature
- [ ] Subscription record saved to dual databases
- [ ] Confirmation email sent to customer
- [ ] Audit log entry created
- [ ] User redirected to success page

### Upgrading/Downgrading Plan

- [ ] Verify user has active subscription
- [ ] Calculate proration amount (if applicable)
- [ ] Update Stripe subscription with new price
- [ ] Update database with new plan details
- [ ] Send plan change confirmation email
- [ ] Log plan change in audit trail
- [ ] Update billing dashboard UI

### Canceling Subscription

- [ ] Verify user owns subscription
- [ ] Collect cancellation reason (optional)
- [ ] Set `cancel_at_period_end: true` (retain access)
- [ ] OR call `stripe.subscriptions.cancel()` (immediate)
- [ ] Update database status
- [ ] Send cancellation confirmation with access-until date
- [ ] Log cancellation for analytics
- [ ] Offer retention incentive (optional)

### Handling Payment Failures

- [ ] Webhook receives `invoice.payment_failed`
- [ ] Mark subscription as `past_due`
- [ ] Send payment failure email with retry link
- [ ] Update payment method prompt in dashboard
- [ ] After 3 failures, mark as `canceled`
- [ ] Send final notice email
- [ ] Disable access to platform

## Proration Strategies

### When to Prorate

**Immediate proration** (charge now):

- Upgrade (monthly → annual): Customer gets credit, charged difference
- Add seats: Charge prorated amount for new seats

**Prorate at period end** (no immediate charge):

- Downgrade (annual → monthly): No refund, change takes effect at renewal
- Remove seats: Refund at next billing cycle

```typescript
// Immediate proration (upgrades)
await stripe.subscriptions.update(subscriptionId, {
  items: [{ id: itemId, price: newPriceId }],
  proration_behavior: "create_prorations",
  billing_cycle_anchor: "now", // Reset billing date
});

// Period-end change (downgrades)
await stripe.subscriptions.update(subscriptionId, {
  items: [{ id: itemId, price: newPriceId }],
  proration_behavior: "none",
  billing_cycle_anchor: "unchanged",
});
```

## Recovery & Dunning

### Payment Retry Logic

Stripe automatically retries failed payments:

- **Day 0**: Initial attempt
- **Day 3**: First retry
- **Day 5**: Second retry
- **Day 7**: Third retry
- **Day 9**: Final retry → cancel if still failing

**Configure retry settings:**

```typescript
await stripe.subscriptions.update(subscriptionId, {
  payment_settings: {
    payment_method_options: {
      card: {
        request_three_d_secure: "automatic",
      },
    },
    save_default_payment_method: "on_subscription",
  },
});
```

### Dunning Email Sequence

```typescript
// Send escalating emails based on retry attempt
const DUNNING_EMAILS = {
  attempt_1: "payment-failed-soft.html", // "Update payment method"
  attempt_2: "payment-failed-urgent.html", // "Action required"
  attempt_3: "payment-failed-final.html", // "Service will be suspended"
  canceled: "subscription-canceled.html", // "Account suspended"
};

async function sendDunningEmail(attemptNumber: number, customerEmail: string) {
  const template =
    DUNNING_EMAILS[`attempt_${attemptNumber}`] || DUNNING_EMAILS.attempt_1;
  await sendEmail({
    to: customerEmail,
    template,
    data: { attemptNumber, maxAttempts: 3 },
  });
}
```

## Integration with Email Composer Skill

```typescript
import { sendBilingualEmail } from "../email-composer";

// Send subscription confirmation
await sendBilingualEmail({
  to: user.email,
  lang: user.preferredLanguage,
  template: "subscription-confirmation",
  data: {
    userName: user.name,
    planName: subscription.planName,
    billingCycle: subscription.billingCycle,
    amount: subscription.amount,
    nextBillingDate: subscription.currentPeriodEnd,
  },
});
```

## Common Issues & Solutions

### Issue 1: Webhook Not Firing

**Symptoms**: Subscription created in Stripe but not in database

**Solutions**:

1. Check webhook endpoint is publicly accessible (not localhost)
2. Verify webhook secret matches environment variable
3. Check Stripe dashboard → Webhooks → Event logs for errors
4. Test with Stripe CLI: `stripe listen --forward-to localhost:9000/api/stripe/webhook`

### Issue 2: Double Charges on Upgrade

**Cause**: Using `billing_cycle_anchor: 'now'` with `proration_behavior: 'create_prorations'`

**Solution**:

```typescript
// For upgrades, reset anchor to avoid double-billing
await stripe.subscriptions.update(subscriptionId, {
  items: [{ id: itemId, price: newPriceId }],
  proration_behavior: "create_prorations",
  billing_cycle_anchor: "now", // ✅ Reset to now
});
```

### Issue 3: Subscription Status Out of Sync

**Cause**: Webhook processing failed or delayed

**Solution**: Add reconciliation check

```typescript
// Periodic sync job (runs daily)
async function syncSubscriptionStatuses() {
  const subscriptions = await db.subscription.findMany({
    where: { status: { in: ["active", "past_due"] } },
  });

  for (const sub of subscriptions) {
    const stripeSubscription = await stripe.subscriptions.retrieve(
      sub.stripeSubscriptionId,
    );

    if (stripeSubscription.status !== sub.status) {
      await saveToBothDatabases("subscription", {
        where: { id: sub.id },
        data: { status: stripeSubscription.status },
      });
    }
  }
}
```

## Testing Checklist

- [ ] Test subscription creation via checkout webhook
- [ ] Test subscription update via webhook
- [ ] Test plan upgrade with proration
- [ ] Test plan downgrade at period end
- [ ] Test cancellation (immediate and at period end)
- [ ] Test reactivation of canceled subscription
- [ ] Test payment failure handling and dunning
- [ ] Test subscription status sync
- [ ] Test idempotency (duplicate webhook events)
- [ ] Test error handling for all Stripe API calls
- [ ] Verify dual-database consistency
- [ ] Test bilingual confirmation emails

## See Also

- [api-development](../api-development/SKILL.md) - API endpoint patterns
- [error-handling](../error-handling/SKILL.md) - Error handling strategies
- [email-composer](../email-composer/SKILL.md) - Billing email templates
- [database-migration](../database-migration/SKILL.md) - Schema changes for subscription tables
- [testing-patterns](../testing-patterns/SKILL.md) - Test structure and mocking
