Menu
Zurück zum Blog
2 min read
SaaS

Subscription Billing mit Stripe

Stripe Subscription Billing implementieren. Checkout, Webhooks, Customer Portal und Metered Billing für SaaS.

StripeSubscriptionBillingWebhooksCustomer PortalMetered Billing
Subscription Billing mit Stripe

Subscription Billing mit Stripe

Meta-Description: Stripe Subscription Billing implementieren. Checkout, Webhooks, Customer Portal und Metered Billing für SaaS.

Keywords: Stripe, Subscription, Billing, Webhooks, Customer Portal, Metered Billing, SaaS, Payment


Einführung

Stripe ist der De-facto-Standard für SaaS Billing. Von einfachen Subscriptions bis komplexem Metered Billing – Stripe bietet alle Tools. Dieser Guide zeigt die komplette Implementation für Next.js Anwendungen.


Stripe Architecture

┌─────────────────────────────────────────────────────────────┐
│              STRIPE BILLING ARCHITECTURE                     │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  User Flow:                                                 │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  1. User clicks "Subscribe"                         │   │
│  │         ↓                                           │   │
│  │  2. Create Stripe Customer (if new)                 │   │
│  │         ↓                                           │   │
│  │  3. Create Checkout Session                         │   │
│  │         ↓                                           │   │
│  │  4. Redirect to Stripe Checkout                     │   │
│  │         ↓                                           │   │
│  │  5. User completes payment                          │   │
│  │         ↓                                           │   │
│  │  6. Stripe sends webhook                            │   │
│  │         ↓                                           │   │
│  │  7. Update database, activate subscription          │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  Key Objects:                                               │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Customer:                                          │   │
│  │  ├── email, name, metadata                         │   │
│  │  └── payment methods, invoices                      │   │
│  │                                                     │   │
│  │  Product:                                           │   │
│  │  ├── name, description                             │   │
│  │  └── has many Prices                               │   │
│  │                                                     │   │
│  │  Price:                                             │   │
│  │  ├── amount, currency, interval                    │   │
│  │  └── recurring or one-time                         │   │
│  │                                                     │   │
│  │  Subscription:                                      │   │
│  │  ├── customer, items (prices)                      │   │
│  │  ├── status: active, past_due, canceled            │   │
│  │  └── billing cycle, trial                          │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  Webhooks to Handle:                                        │
│  ├── checkout.session.completed                            │
│  ├── customer.subscription.created                         │
│  ├── customer.subscription.updated                         │
│  ├── customer.subscription.deleted                         │
│  ├── invoice.paid                                          │
│  ├── invoice.payment_failed                                │
│  └── customer.updated                                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Stripe Setup

// lib/stripe/client.ts
import Stripe from 'stripe';

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error('STRIPE_SECRET_KEY is required');
}

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2024-06-20',
  typescript: true
});

// lib/stripe/config.ts
export const STRIPE_CONFIG = {
  prices: {
    pro_monthly: process.env.STRIPE_PRICE_PRO_MONTHLY!,
    pro_yearly: process.env.STRIPE_PRICE_PRO_YEARLY!,
    enterprise_monthly: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY!,
    enterprise_yearly: process.env.STRIPE_PRICE_ENTERPRISE_YEARLY!
  },
  products: {
    pro: process.env.STRIPE_PRODUCT_PRO!,
    enterprise: process.env.STRIPE_PRODUCT_ENTERPRISE!
  },
  webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!
};

// Map price IDs to plan names
export const PRICE_TO_PLAN: Record<string, string> = {
  [STRIPE_CONFIG.prices.pro_monthly]: 'pro',
  [STRIPE_CONFIG.prices.pro_yearly]: 'pro',
  [STRIPE_CONFIG.prices.enterprise_monthly]: 'enterprise',
  [STRIPE_CONFIG.prices.enterprise_yearly]: 'enterprise'
};

Customer Management

// lib/stripe/customers.ts
import { stripe } from './client';
import { db } from '@/lib/db';

export async function getOrCreateStripeCustomer(
  userId: string
): Promise<string> {
  // Check if user already has a Stripe customer ID
  const user = await db.user.findUnique({
    where: { id: userId },
    select: { stripeCustomerId: true, email: true, name: true }
  });

  if (!user) {
    throw new Error('User not found');
  }

  if (user.stripeCustomerId) {
    return user.stripeCustomerId;
  }

  // Create new Stripe customer
  const customer = await stripe.customers.create({
    email: user.email!,
    name: user.name || undefined,
    metadata: {
      userId
    }
  });

  // Save customer ID to database
  await db.user.update({
    where: { id: userId },
    data: { stripeCustomerId: customer.id }
  });

  return customer.id;
}

export async function getCustomerSubscription(
  customerId: string
): Promise<Stripe.Subscription | null> {
  const subscriptions = await stripe.subscriptions.list({
    customer: customerId,
    status: 'all',
    limit: 1,
    expand: ['data.default_payment_method']
  });

  return subscriptions.data[0] || null;
}

export async function updateCustomerEmail(
  customerId: string,
  email: string
): Promise<void> {
  await stripe.customers.update(customerId, { email });
}

Checkout Session

// lib/stripe/checkout.ts
import { stripe } from './client';
import { STRIPE_CONFIG } from './config';
import { getOrCreateStripeCustomer } from './customers';

interface CreateCheckoutOptions {
  userId: string;
  priceId: string;
  quantity?: number;
  trialDays?: number;
  couponId?: string;
  metadata?: Record<string, string>;
}

export async function createCheckoutSession(
  options: CreateCheckoutOptions
): Promise<string> {
  const customerId = await getOrCreateStripeCustomer(options.userId);

  const sessionConfig: Stripe.Checkout.SessionCreateParams = {
    customer: customerId,
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [
      {
        price: options.priceId,
        quantity: options.quantity || 1
      }
    ],
    subscription_data: {
      trial_period_days: options.trialDays,
      metadata: {
        userId: options.userId,
        ...options.metadata
      }
    },
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    allow_promotion_codes: true,
    billing_address_collection: 'auto',
    customer_update: {
      address: 'auto',
      name: 'auto'
    }
  };

  // Add coupon if provided
  if (options.couponId) {
    sessionConfig.discounts = [{ coupon: options.couponId }];
  }

  const session = await stripe.checkout.sessions.create(sessionConfig);

  return session.url!;
}

// API Route
// app/api/checkout/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { createCheckoutSession } from '@/lib/stripe/checkout';

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

    const { priceId, quantity } = await request.json();

    const checkoutUrl = await createCheckoutSession({
      userId: session.user.id,
      priceId,
      quantity,
      trialDays: 14
    });

    return NextResponse.json({ url: checkoutUrl });
  } catch (error) {
    console.error('Checkout error:', error);
    return NextResponse.json(
      { error: 'Failed to create checkout session' },
      { status: 500 }
    );
  }
}

Webhook Handler

// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe/client';
import { STRIPE_CONFIG, PRICE_TO_PLAN } from '@/lib/stripe/config';
import { db } from '@/lib/db';
import Stripe from 'stripe';

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

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      STRIPE_CONFIG.webhookSecret
    );
  } catch (error) {
    console.error('Webhook signature verification failed:', error);
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  try {
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
        break;

      case 'customer.subscription.created':
      case 'customer.subscription.updated':
        await handleSubscriptionChange(event.data.object as Stripe.Subscription);
        break;

      case 'customer.subscription.deleted':
        await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
        break;

      case 'invoice.paid':
        await handleInvoicePaid(event.data.object as Stripe.Invoice);
        break;

      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object as Stripe.Invoice);
        break;

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return NextResponse.json({ received: true });
  } catch (error) {
    console.error('Webhook handler error:', error);
    return NextResponse.json(
      { error: 'Webhook handler failed' },
      { status: 500 }
    );
  }
}

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const customerId = session.customer as string;
  const subscriptionId = session.subscription as string;

  // Get user by Stripe customer ID
  const user = await db.user.findFirst({
    where: { stripeCustomerId: customerId }
  });

  if (!user) {
    throw new Error(`User not found for customer: ${customerId}`);
  }

  // Get subscription details
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
  const priceId = subscription.items.data[0].price.id;
  const plan = PRICE_TO_PLAN[priceId] || 'free';

  // Update user subscription
  await db.user.update({
    where: { id: user.id },
    data: {
      stripeSubscriptionId: subscriptionId,
      plan,
      subscriptionStatus: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000)
    }
  });

  // Send welcome email
  await sendWelcomeEmail(user.email!, plan);
}

async function handleSubscriptionChange(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string;

  const user = await db.user.findFirst({
    where: { stripeCustomerId: customerId }
  });

  if (!user) return;

  const priceId = subscription.items.data[0].price.id;
  const plan = PRICE_TO_PLAN[priceId] || 'free';

  await db.user.update({
    where: { id: user.id },
    data: {
      plan,
      subscriptionStatus: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end
    }
  });
}

async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string;

  const user = await db.user.findFirst({
    where: { stripeCustomerId: customerId }
  });

  if (!user) return;

  await db.user.update({
    where: { id: user.id },
    data: {
      plan: 'free',
      subscriptionStatus: 'canceled',
      stripeSubscriptionId: null
    }
  });
}

async function handleInvoicePaid(invoice: Stripe.Invoice) {
  // Record payment in database
  if (invoice.subscription) {
    await db.payment.create({
      data: {
        stripeInvoiceId: invoice.id,
        stripeCustomerId: invoice.customer as string,
        amount: invoice.amount_paid,
        currency: invoice.currency,
        status: 'paid',
        paidAt: new Date()
      }
    });
  }
}

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string;

  const user = await db.user.findFirst({
    where: { stripeCustomerId: customerId }
  });

  if (!user) return;

  // Update subscription status
  await db.user.update({
    where: { id: user.id },
    data: { subscriptionStatus: 'past_due' }
  });

  // Send payment failed email
  await sendPaymentFailedEmail(user.email!);
}

Customer Portal

// lib/stripe/portal.ts
import { stripe } from './client';

export async function createBillingPortalSession(
  customerId: string,
  returnUrl?: string
): Promise<string> {
  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: returnUrl || `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`
  });

  return session.url;
}

// API Route
// app/api/billing/portal/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { createBillingPortalSession } from '@/lib/stripe/portal';
import { db } from '@/lib/db';

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

    const user = await db.user.findUnique({
      where: { id: session.user.id },
      select: { stripeCustomerId: true }
    });

    if (!user?.stripeCustomerId) {
      return NextResponse.json(
        { error: 'No billing account found' },
        { status: 400 }
      );
    }

    const portalUrl = await createBillingPortalSession(user.stripeCustomerId);

    return NextResponse.json({ url: portalUrl });
  } catch (error) {
    console.error('Portal error:', error);
    return NextResponse.json(
      { error: 'Failed to create portal session' },
      { status: 500 }
    );
  }
}

Metered Billing

// lib/stripe/metered.ts
import { stripe } from './client';
import { db } from '@/lib/db';

export async function reportUsage(
  userId: string,
  quantity: number,
  idempotencyKey?: string
): Promise<void> {
  const user = await db.user.findUnique({
    where: { id: userId },
    select: { stripeSubscriptionId: true }
  });

  if (!user?.stripeSubscriptionId) {
    throw new Error('User has no active subscription');
  }

  // Get the subscription item for metered billing
  const subscription = await stripe.subscriptions.retrieve(
    user.stripeSubscriptionId,
    { expand: ['items.data.price'] }
  );

  const meteredItem = subscription.items.data.find(
    item => (item.price as Stripe.Price).recurring?.usage_type === 'metered'
  );

  if (!meteredItem) {
    throw new Error('No metered billing item found');
  }

  // Report usage
  await stripe.subscriptionItems.createUsageRecord(
    meteredItem.id,
    {
      quantity,
      timestamp: Math.floor(Date.now() / 1000),
      action: 'increment'
    },
    {
      idempotencyKey
    }
  );
}

// Get usage for current period
export async function getCurrentUsage(userId: string): Promise<{
  used: number;
  limit: number | null;
}> {
  const user = await db.user.findUnique({
    where: { id: userId },
    select: { stripeSubscriptionId: true }
  });

  if (!user?.stripeSubscriptionId) {
    return { used: 0, limit: null };
  }

  const subscription = await stripe.subscriptions.retrieve(
    user.stripeSubscriptionId
  );

  const meteredItem = subscription.items.data.find(
    item => item.price.recurring?.usage_type === 'metered'
  );

  if (!meteredItem) {
    return { used: 0, limit: null };
  }

  // Get usage records
  const usageRecords = await stripe.subscriptionItems.listUsageRecordSummaries(
    meteredItem.id,
    { limit: 1 }
  );

  const currentUsage = usageRecords.data[0]?.total_usage || 0;

  return {
    used: currentUsage,
    limit: null // or get from subscription metadata
  };
}

Subscription Management UI

// components/billing/SubscriptionStatus.tsx
'use client';

import { useState } from 'react';
import { useSubscription } from '@/hooks/useSubscription';

export function SubscriptionStatus() {
  const { subscription, isLoading } = useSubscription();
  const [isPortalLoading, setIsPortalLoading] = useState(false);

  const openBillingPortal = async () => {
    setIsPortalLoading(true);
    try {
      const res = await fetch('/api/billing/portal', { method: 'POST' });
      const { url } = await res.json();
      window.location.href = url;
    } catch (error) {
      console.error('Portal error:', error);
    }
    setIsPortalLoading(false);
  };

  if (isLoading) return <div>Loading...</div>;

  return (
    <div className="bg-white rounded-lg shadow p-6">
      <h2 className="text-lg font-semibold mb-4">Subscription</h2>

      <div className="space-y-4">
        <div className="flex justify-between">
          <span className="text-gray-600">Current Plan</span>
          <span className="font-semibold capitalize">
            {subscription?.plan || 'Free'}
          </span>
        </div>

        <div className="flex justify-between">
          <span className="text-gray-600">Status</span>
          <StatusBadge status={subscription?.status || 'inactive'} />
        </div>

        {subscription?.currentPeriodEnd && (
          <div className="flex justify-between">
            <span className="text-gray-600">
              {subscription.cancelAtPeriodEnd ? 'Cancels on' : 'Renews on'}
            </span>
            <span>
              {new Date(subscription.currentPeriodEnd).toLocaleDateString()}
            </span>
          </div>
        )}

        <hr />

        <button
          onClick={openBillingPortal}
          disabled={isPortalLoading}
          className="w-full py-2 bg-gray-100 hover:bg-gray-200 rounded-lg"
        >
          {isPortalLoading ? 'Loading...' : 'Manage Subscription'}
        </button>
      </div>
    </div>
  );
}

function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    active: 'bg-green-100 text-green-800',
    trialing: 'bg-blue-100 text-blue-800',
    past_due: 'bg-yellow-100 text-yellow-800',
    canceled: 'bg-red-100 text-red-800',
    inactive: 'bg-gray-100 text-gray-800'
  };

  return (
    <span className={`px-2 py-1 rounded-full text-sm ${colors[status] || colors.inactive}`}>
      {status.replace('_', ' ')}
    </span>
  );
}

Best Practices

AspectRecommendation
**Webhooks**Always verify signatures
**Idempotency**Use idempotency keys
**Retries**Handle webhook retries
**Testing**Use Stripe CLI for local testing
**Errors**Log all Stripe errors
**Security**Never expose secret keys

Fazit

Stripe Subscription Billing erfordert:

  1. Customer Management: Stripe Customer ID syncen
  2. Webhooks: Alle relevanten Events handeln
  3. Portal: Kunden Self-Service ermöglichen
  4. Metered: Usage-Based Billing für Flexibilität

Robustes Billing ist Grundlage für SaaS-Erfolg.


Bildprompts

  1. "Stripe checkout page, credit card form with security badges"
  2. "Subscription management dashboard, plan upgrade flow"
  3. "Webhook event flow diagram, Stripe to application"

Quellen