Stripe Billing Implementation: Subscriptions, Invoices, and Webhooks

Complete Stripe billing implementation: subscription checkout, invoice management, webhook event handling, refunds, failed payment recovery, and NestJS integration patterns.

E
ECOSIRE Research and Development Team
|19. März 20269 Min. Lesezeit2.1k Wörter|

Stripe Billing-Implementierung: Abonnements, Rechnungen und Webhooks

Stripe ist der De-facto-Standard für die Abrechnungsinfrastruktur, aber seine korrekte Implementierung – idempotente Handhabung von Webhooks, Verwaltung von Abonnementstatusübergängen und Wiederherstellung nach fehlgeschlagenen Zahlungen – erfordert wesentlich mehr Sorgfalt, als die Dokumentation zu den ersten Schritten vermuten lässt. Ein Zahlungssystem, das 99 % der Zeit funktioniert und das andere 1 % lautlos ausfällt, ist schlimmer als eines, das zu 95 % laut ausfällt.

Dieser Leitfaden behandelt eine Produktions-Stripe-Implementierung mit 6 Webhook-Ereignistypen, Abonnement-Lebenszyklusverwaltung, automatischer Lizenzbereitstellung und Rückerstattungsabwicklung. Die Codebeispiele verwenden NestJS 11, aber die Muster gelten für jedes Backend-Framework.

Wichtige Erkenntnisse

– Gewähren Sie niemals Zugriff basierend auf der Checkout-Umleitung – warten Sie immer auf den checkout.session.completed-Webhook – Überprüfen Sie die Webhook-Signaturen mit stripe.webhooks.constructEvent(), bevor Sie ein Ereignis verarbeiten – Machen Sie alle Webhook-Handler idempotent – Stripe wiederholt fehlgeschlagene Webhooks bis zu 72 Stunden lang

  • Speichern Sie Stripe-Kunden-IDs, Abonnement-IDs und Preis-IDs in Ihrer Datenbank – fragen Sie Stripe nicht erneut nach allem ab – Behandeln Sie invoice.payment_failed, um Mahn-E-Mails und eine Abonnementsperre auszulösen
  • charge.refunded und checkout.session.completed decken zusammen den gesamten Kauflebenszyklus ab
  • Verwenden Sie das Feld metadata von Stripe, um Stripe-Objekte mit Ihren internen Datensätzen zu verknüpfen
  • Protokollieren Sie niemals unformatierte Stripe-Webhook-Payloads – sie enthalten vertrauliche Kartendaten

Projekt-Setup

pnpm add stripe
// billing/stripe.client.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
  typescript: true,
});

Speichern Sie diese Umgebungsvariablen:

STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...

Erstellung einer Checkout-Sitzung

Der Checkout-Ablauf: Ihr Backend erstellt eine Stripe Checkout-Sitzung, das Frontend leitet zu Stripe weiter, Stripe kassiert die Zahlung und ruft dann Ihren Webhook auf. Erledigen Sie die Zahlung niemals selbst – leiten Sie immer zu Stripe Checkout weiter.

// billing/billing.service.ts
import Stripe from 'stripe';
import { Injectable, BadRequestException } from '@nestjs/common';
import { stripe } from './stripe.client';

@Injectable()
export class BillingService {
  async createCheckoutSession(
    userId: string,
    orgId: string,
    priceId: string,
    productId: string
  ) {
    // Get or create Stripe customer
    const customer = await this.getOrCreateCustomer(userId, orgId);

    const session = await stripe.checkout.sessions.create({
      customer: customer.stripeCustomerId,
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      mode: 'payment', // or 'subscription' for recurring
      success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.FRONTEND_URL}/services`,
      metadata: {
        userId,
        organizationId: orgId,
        productId,
      },
      allow_promotion_codes: true,
      invoice_creation: {
        enabled: true,
      },
    });

    return { url: session.url };
  }

  async createSubscriptionCheckout(
    userId: string,
    orgId: string,
    priceId: string,
    productId: string
  ) {
    const customer = await this.getOrCreateCustomer(userId, orgId);

    const session = await stripe.checkout.sessions.create({
      customer: customer.stripeCustomerId,
      payment_method_types: ['card'],
      line_items: [{ price: priceId, quantity: 1 }],
      mode: 'subscription',
      subscription_data: {
        metadata: {
          userId,
          organizationId: orgId,
          productId,
        },
        trial_period_days: 14,
      },
      success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.FRONTEND_URL}/pricing`,
      metadata: {
        userId,
        organizationId: orgId,
        productId,
      },
    });

    return { url: session.url };
  }

  private async getOrCreateCustomer(userId: string, orgId: string) {
    // Check if customer already exists in your DB
    const existing = await db.query.billingCustomers.findFirst({
      where: and(
        eq(billingCustomers.userId, userId),
        eq(billingCustomers.organizationId, orgId)
      ),
    });

    if (existing) return existing;

    // Fetch user details for Stripe customer creation
    const user = await db.query.users.findFirst({
      where: eq(users.id, userId),
    });

    const stripeCustomer = await stripe.customers.create({
      email: user.email,
      name: user.name,
      metadata: {
        userId,
        organizationId: orgId,
      },
    });

    const [customer] = await db
      .insert(billingCustomers)
      .values({
        userId,
        organizationId: orgId,
        stripeCustomerId: stripeCustomer.id,
      })
      .returning();

    return customer;
  }
}

Webhook-Handler

Der Webhook-Handler ist der kritischste Teil der Stripe-Integration. Jedes Ereignis muss idempotent sein:

// billing/webhook.controller.ts
import {
  Controller,
  Post,
  Req,
  Headers,
  RawBodyRequest,
  HttpCode,
} from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
import { stripe } from './stripe.client';
import { Public } from '../auth/decorators/public.decorator';
import { BillingWebhookService } from './billing-webhook.service';

@Controller('billing/webhook')
export class WebhookController {
  constructor(private webhookService: BillingWebhookService) {}

  @Post()
  @Public()
  @HttpCode(200) // Always return 200 — Stripe retries on non-2xx
  async handleWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Headers('stripe-signature') signature: string
  ) {
    let event: Stripe.Event;

    try {
      event = stripe.webhooks.constructEvent(
        req.rawBody!, // Requires raw body middleware
        signature,
        process.env.STRIPE_WEBHOOK_SECRET!
      );
    } catch (err) {
      // Invalid signature — do not process
      throw new Error(`Webhook signature verification failed: ${err.message}`);
    }

    // Route to specific handler
    switch (event.type) {
      case 'checkout.session.completed':
        await this.webhookService.handleCheckoutCompleted(
          event.data.object as Stripe.Checkout.Session
        );
        break;

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

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

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

      case 'customer.subscription.updated':
        await this.webhookService.handleSubscriptionUpdated(
          event.data.object as Stripe.Subscription
        );
        break;

      case 'charge.refunded':
        await this.webhookService.handleChargeRefunded(
          event.data.object as Stripe.Charge
        );
        break;

      default:
        // Ignore unhandled event types
        break;
    }

    return { received: true };
  }
}

Wichtig: NestJS benötigt rawBody: true in NestFactory.create() für die Stripe-Signaturüberprüfung:

// main.ts
const app = await NestFactory.create(AppModule, {
  rawBody: true,
});

Checkout Completed Handler

Dies ist der zentrale Bereitstellungshandler. Machen Sie es idempotent:

// billing/billing-webhook.service.ts
@Injectable()
export class BillingWebhookService {
  async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
    // Check if already processed (idempotency)
    const existing = await db.query.orders.findFirst({
      where: eq(orders.stripeSessionId, session.id),
    });

    if (existing) {
      return; // Already processed — Stripe is retrying
    }

    const { userId, organizationId, productId } = session.metadata!;

    // Create order in transaction
    await db.transaction(async (tx) => {
      // Create order
      const [order] = await tx
        .insert(orders)
        .values({
          userId,
          organizationId,
          productId,
          stripeSessionId: session.id,
          stripePaymentIntentId: session.payment_intent as string,
          amount: session.amount_total! / 100, // Stripe uses cents
          currency: session.currency!,
          status: 'completed',
        })
        .returning();

      // Generate license key
      const licenseKey = generateLicenseKey(productId);

      // Create license
      await tx.insert(licenses).values({
        orderId: order.id,
        userId,
        organizationId,
        productId,
        licenseKey,
        status: 'active',
        issuedAt: new Date(),
      });
    });

    // Send confirmation email (non-blocking — don't fail the webhook)
    this.emailService
      .sendPurchaseConfirmation(userId, productId)
      .catch((err) => this.logger.error('Email send failed', err));
  }

  async handleInvoicePaid(invoice: Stripe.Invoice) {
    // For subscriptions — renewal
    if (!invoice.subscription) return;

    // Find existing subscription/license and extend it
    const license = await db.query.licenses.findFirst({
      where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
    });

    if (!license) return;

    await db
      .update(licenses)
      .set({
        status: 'active',
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 days
      })
      .where(eq(licenses.id, license.id));

    // Log renewal in audit table
    await db.insert(licenseAuditLogs).values({
      licenseId: license.id,
      action: 'renewed',
      details: { invoiceId: invoice.id },
    });
  }

  async handlePaymentFailed(invoice: Stripe.Invoice) {
    if (!invoice.subscription) return;

    const license = await db.query.licenses.findFirst({
      where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
    });

    if (!license) return;

    // Grace period before suspension (e.g., 3 days)
    const gracePeriod = new Date();
    gracePeriod.setDate(gracePeriod.getDate() + 3);

    if (new Date() > gracePeriod) {
      await db
        .update(licenses)
        .set({ status: 'suspended' })
        .where(eq(licenses.id, license.id));
    }

    // Send dunning email
    this.emailService
      .sendPaymentFailedNotification(license.userId, invoice)
      .catch(console.error);
  }

  async handleChargeRefunded(charge: Stripe.Charge) {
    const order = await db.query.orders.findFirst({
      where: eq(orders.stripePaymentIntentId, charge.payment_intent as string),
    });

    if (!order) return;

    await db.transaction(async (tx) => {
      // Update order status
      await tx
        .update(orders)
        .set({ status: 'refunded' })
        .where(eq(orders.id, order.id));

      // Revoke associated licenses
      await tx
        .update(licenses)
        .set({ status: 'revoked', revokedAt: new Date() })
        .where(eq(licenses.orderId, order.id));
    });

    // Log audit trail
    await db.insert(licenseAuditLogs).values({
      licenseId: (await db.query.licenses.findFirst({
        where: eq(licenses.orderId, order.id),
      }))!.id,
      action: 'revoked',
      details: { reason: 'refund', chargeId: charge.id },
    });
  }

  async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
    await db
      .update(licenses)
      .set({ status: 'cancelled', cancelledAt: new Date() })
      .where(eq(licenses.stripeSubscriptionId, subscription.id));
  }

  async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
    const status = subscription.status === 'active' ? 'active' : 'suspended';

    await db
      .update(licenses)
      .set({ status })
      .where(eq(licenses.stripeSubscriptionId, subscription.id));
  }
}

Kundenportal

Ermöglichen Sie Kunden, ihre eigenen Abonnements zu verwalten, ohne den Support zu kontaktieren:

async createPortalSession(userId: string, orgId: string) {
  const customer = await db.query.billingCustomers.findFirst({
    where: and(
      eq(billingCustomers.userId, userId),
      eq(billingCustomers.organizationId, orgId)
    ),
  });

  if (!customer) {
    throw new NotFoundException('No billing account found');
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: customer.stripeCustomerId,
    return_url: `${process.env.FRONTEND_URL}/dashboard/billing`,
  });

  return { url: portalSession.url };
}

Über das Kundenportal können Benutzer Abonnements kündigen, Zahlungsmethoden aktualisieren, Rechnungen herunterladen und den Rechnungsverlauf anzeigen – ohne dass eine benutzerdefinierte Benutzeroberfläche erforderlich ist.


Frontend-Integration

// web: lib/billing.ts
'use client';

import { apiFetch } from '@/lib/api';

export async function initiateCheckout(priceId: string, productId: string) {
  const { url } = await apiFetch<{ url: string }>('/billing/checkout', {
    method: 'POST',
    body: JSON.stringify({ priceId, productId }),
  });

  // Redirect to Stripe Checkout
  window.location.href = url;
}

export async function openCustomerPortal() {
  const { url } = await apiFetch<{ url: string }>('/billing/portal', {
    method: 'POST',
  });

  window.open(url, '_blank');
}

Webhooks lokal testen

Die Stripe-CLI ist für die lokale Webhook-Entwicklung unerlässlich:

# Install Stripe CLI
npm install -g stripe

# Listen and forward webhooks to localhost
stripe listen --forward-to localhost:3001/billing/webhook

# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted

# View event logs
stripe events list

Die CLI stellt Ihr lokales Webhook-Geheimnis bereit – verwenden Sie es in Ihrem .env.local:

STRIPE_WEBHOOK_SECRET=whsec_test_... # From `stripe listen` output

Häufige Fallstricke und Lösungen

Falle 1: Bereitstellung des Zugriffs über die Checkout-Umleitung statt über den Webhook

Die Weiterleitung bei erfolgreichem Bezahlvorgang kann ausgelöst werden durch: Browser zurück/vor, doppelte Tabs oder Netzwerkwiederholung. Stellen Sie immer basierend auf dem checkout.session.completed-Webhook bereit, nicht auf der Weiterleitungs-URL.

Falle 2: Webhook-Wiederholungsversuche werden nicht verarbeitet (Idempotenz)

Stripe versucht 72 Stunden lang fehlgeschlagene Webhooks erneut. Ohne Idempotenzprüfungen führen Wiederholungsversuche zu doppelten Bestellungen, Lizenzen und E-Mails. Speichern Sie die Stripe-Sitzungs-ID mit einer eindeutigen Einschränkung in Ihrer Auftragstabelle – doppelte Einfügungen schlagen ordnungsgemäß fehl.

Falle 3: Falsche Verwendung der Raw-Body-Middleware

Für die Überprüfung der Stripe-Signatur ist der rohe, ungeparste Anforderungstext erforderlich. Wenn der JSON-Body-Parser von Express zuerst ausgeführt wird, ist der Rohkörper verschwunden. Verwenden Sie rawBody: true in NestJS und greifen Sie auf req.rawBody zu oder konfigurieren Sie die JSON-Middleware, um den Rohtext speziell für die Webhook-Route beizubehalten.

Falle 4: Fehlende Wiederholungslogik für Stripe-API-Aufrufe

Stripe-API-Aufrufe können vorübergehend fehlschlagen. Wiederholungslogik für idempotente Vorgänge hinzufügen:

import Stripe from 'stripe';

async function retryStripeOperation<T>(
  operation: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (err) {
      if (err instanceof Stripe.errors.StripeConnectionError && attempt < maxRetries) {
        await new Promise((r) => setTimeout(r, 1000 * attempt));
        continue;
      }
      throw err;
    }
  }
  throw new Error('Max retries exceeded');
}

Häufig gestellte Fragen

Soll ich Stripe Checkout oder benutzerdefinierte Zahlungsformulare verwenden?

Verwenden Sie immer Stripe Checkout, es sei denn, Sie haben spezielle Anforderungen, die dies unmöglich machen (selten). Checkout übernimmt die PCI-Konformität, die 3D-Secure-Authentifizierung, Apple Pay, Google Pay, SEPA und Dutzende anderer Zahlungsmethoden automatisch. Benutzerdefinierte Zahlungsformulare (mit Stripe Elements) erfordern, dass Sie die PCI-Konformität selbst einhalten und jede Zahlungsmethode manuell implementieren. Für 95 % der SaaS-Produkte ist Checkout die richtige Wahl.

Wie gehe ich mit dem Testmodus von Stripe im Vergleich zum Live-Modus um?

Verwenden Sie separate Umgebungskonfigurationen: STRIPE_SECRET_KEY=sk_test_... in der Entwicklung und STRIPE_SECRET_KEY=sk_live_... in der Produktion. Der Stripe-Testmodus ist vollständig vom Live-Modus isoliert – keine Testgebühren wirken sich auf echtes Geld aus und im Testmodus werden keine Live-Daten angezeigt. Verwenden Sie stripe.mode in Ihrem Code, um zu überprüfen, ob Sie den richtigen Schlüssel verwenden, und fügen Sie in Ihrer Admin-Benutzeroberfläche eine auffällige Anzeige hinzu, die anzeigt, in welchem Modus Sie sich befinden.

Was passiert, wenn mein Webhook-Server ausgefallen ist und Stripe nicht liefern kann?

Stripe wiederholt fehlgeschlagene Webhooks nach einem exponentiellen Backoff-Zeitplan für bis zu 72 Stunden: 5 Minuten, 30 Minuten, 1 Stunde, 2 Stunden und zunehmende Intervalle. Nach 72 Stunden gilt die Veranstaltung als gescheitert. Verwenden Sie das Ereignis-Dashboard von Stripe, um fehlgeschlagene Ereignisse manuell erneut zu übermitteln, oder fragen Sie stripe.events.list() ab und spielen Sie sie erneut ab. Gestalten Sie Ihre Handler so, dass sie idempotent sind, sodass eine erneute Zustellung sicher ist.

Wie setze ich eine zählerbasierte/nutzungsbasierte Abrechnung um?

Stripe Billing unterstützt die gemessene Nutzung über stripe.subscriptionItems.createUsageRecord(). Melden Sie die Nutzung am Ende jedes Abrechnungszeitraums mit der verbrauchten Menge. Preisgemessene Pläne in Ihrem Stripe-Dashboard als „gemessene“ Preise. Das wichtigste Implementierungsdetail: Melden Sie die Nutzung vor dem Ende des Abrechnungszeitraums, nicht danach – Stripe schließt die Rechnung am Ende des Zeitraums ab.

Wie soll ich Stripe-IDs in meiner Datenbank speichern?

Speichern Sie alle Stripe-IDs als text-Spalten (nicht numerisch). Erstellen Sie Indizes für stripe_customer_id, stripe_subscription_id und stripe_payment_intent_id – Sie werden anhand dieser Datensätze häufig nachschlagen. Fügen Sie gegebenenfalls eindeutige Einschränkungen hinzu (ein Kundendatensatz pro Benutzer). Speichern Sie niemals Kreditkartennummern oder Rohzahlungsdaten in Ihrer eigenen Datenbank.


Nächste Schritte

Die korrekte Implementierung der Stripe-Abrechnung – mit Webhook-Idempotenz, Abonnementlebenszyklusverwaltung, automatischer Bereitstellung und ordnungsgemäßer Rückerstattungsabwicklung – ist ein nicht triviales technisches Unterfangen. ECOSIRE verarbeitet 6 Stripe-Webhook-Ereignistypen in der Produktion mit automatischer Lizenzbereitstellung und Kundenportalintegration.

Wenn Sie eine Stripe-Integration, eine Abonnement-Abrechnungsinfrastruktur oder Full-Stack-SaaS-Entwicklungsunterstützung benötigen, entdecken Sie unsere Entwicklungsdienste.

E

Geschrieben von

ECOSIRE Research and Development Team

Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.

Chatten Sie auf WhatsApp