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
|2026年3月19日7 分钟阅读1.4k 字数|

Stripe Billing 实施:订阅、发票和 Webhooks

Stripe 是计费基础设施事实上的标准,但正确实施它(幂等地处理 Webhook、管理订阅状态转换以及从失败的支付中恢复)比入门文档建议的要小心得多。一个支付系统在 99% 的时间里都能正常工作,而在另外 1% 的时间里默默地失败,比在 95% 的情况下大声失败的支付系统更糟糕。

本指南涵盖了具有 6 个 Webhook 事件类型的生产 Stripe 实现、订阅生命周期管理、自动许可证配置和退款处理。代码示例使用 NestJS 11,但这些模式适用于任何后端框架。

要点

  • 切勿根据结帐重定向提供访问权限 - 始终等待 checkout.session.completed webhook
  • 在处理任何事件之前使用 stripe.webhooks.constructEvent() 验证 Webhook 签名
  • 使所有 Webhook 处理程序幂等 - Stripe 重试失败的 Webhook 长达 72 小时
  • 将 Stripe 客户 ID、订阅 ID 和价格 ID 存储在数据库中 — 不要重新查询 Stripe 的所有信息
  • 处理 invoice.payment_failed 以触发催款电子邮件和订阅暂停
  • charge.refundedcheckout.session.completed 一起覆盖整个购买生命周期
  • 使用 Stripe 的 metadata 字段将 Stripe 对象链接到您的内部记录
  • 切勿记录原始 Stripe Webhook 有效负载 - 它们包含敏感的卡数据

项目设置

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,
});

存储这些环境变量:

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

检查会话创建

结帐流程:您的后端创建一个 Stripe Checkout 会话,前端重定向到 Stripe,Stripe 收集付款,然后调用您的 webhook。切勿自行处理付款 - 始终重定向至 Stripe Checkout。

// 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 处理程序

Webhook 处理程序是 Stripe 集成中最关键的部分。每个事件都必须是幂等的:

// 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 };
  }
}

重要:NestJS 需要 NestFactory.create() 中的 rawBody: true 来进行 Stripe 签名验证:

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

签出已完成的处理程序

这是核心配置处理程序。使其幂等:

// 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));
  }
}

客户门户

允许客户管理自己的订阅而无需联系支持人员:

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 };
}

客户门户允许用户取消订阅、更新付款方式、下载发票和查看账单历史记录,无需任何自定义 UI 工作。


前端集成

// 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');
}

本地测试 Webhook

Stripe CLI 对于本地 Webhook 开发至关重要:

# 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

CLI 提供您的本地 Webhook 机密 — 在您的 .env.local 中使用它:

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

常见陷阱和解决方案

陷阱 1:在结帐重定向而不是 Webhook 上配置访问权限

结账成功重定向可以通过以下方式触发:浏览器后退/前进、重复选项卡或网络重试。始终基于 checkout.session.completed webhook 进行配置,而不是重定向 URL。

陷阱 2:不处理 webhook 重试(幂等性)

Stripe 重试失败的 webhook 72 小时。如果没有幂等性检查,重试会创建重复的订单、许可证和电子邮件。将 Stripe 会话 ID 存储在具有唯一约束的订单表中 — 重复插入会正常失败。

陷阱 3:不正确地使用原始主体中间件

Stripe 签名验证需要原始的、未解析的请求正文。如果 Express 的 JSON 主体解析器首先运行,则原始主体就会消失。在 NestJS 中使用 rawBody: true 并访问 req.rawBody,或配置 JSON 中间件以专门保留 webhook 路由的原始正文。

陷阱 4:缺少 Stripe API 调用的重试逻辑

Stripe API 调用可能会暂时失败。为幂等操作添加重试逻辑:

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');
}

常见问题

我应该使用 Stripe Checkout 还是自定义付款方式?

始终使用 Stripe Checkout,除非您有无法使用的特定要求(罕见)。 Checkout 自动处理 PCI 合规性、3D 安全身份验证、Apple Pay、Google Pay、SEPA 和数十种其他支付方式。自定义付款表单(使用 Stripe Elements)要求您自行维护 PCI 合规性并手动实施每种付款方式。对于 95% 的 SaaS 产品来说,Checkout 是正确的选择。

如何处理 Stripe 的测试模式与实时模式?

使用单独的环境配置:开发中使用 STRIPE_SECRET_KEY=sk_test_... ,生产中使用 STRIPE_SECRET_KEY=sk_live_... 。 Stripe 测试模式与实时模式完全隔离——测试费用不会影响真实货币,并且测试模式下不会出现实时数据。在代码中使用 stripe.mode 来验证您使用的是正确的密钥,并在管理 UI 中添加一个显着的指示器来显示您所处的模式。

如果我的 webhook 服务器关闭且 Stripe 无法交付,会发生什么情况?

Stripe 按照指数退避计划重试失败的 Webhooks,最长可达 72 小时:5 分钟、30 分钟、1 小时、2 小时以及递增的间隔。 72小时后,该活动被视为失败。使用 Stripe 的事件仪表板手动重新传送失败的事件,或查询 stripe.events.list() 并重播它们。将您的处理程序设计为幂等的,以便重新传递是安全的。

如何实施计量/基于使用情况的计费?

Stripe Billing 支持通过 stripe.subscriptionItems.createUsageRecord() 计量使用。在每个计费周期结束时报告使用情况以及消耗的数量。在 Stripe 仪表板中将计量计划定价为“计量”定价。关键实施细节:在计费周期结束之前而不是之后报告使用情况 — Stripe 在周期结束时完成发票。

我应该如何在数据库中存储 Stripe ID?

将所有 Stripe ID 存储为 text 列(非数字)。在 stripe_customer_idstripe_subscription_idstripe_payment_intent_id 上创建索引 — 您将经常通过这些索引查找记录。在适当的情况下添加唯一约束(每个用户一条客户记录)。切勿将信用卡号或原始付款数据存储在您自己的数据库中。


后续步骤

正确实施 Stripe 计费(通过 Webhook 幂等性、订阅生命周期管理、自动配置和正确的退款处理)是一项艰巨的工程任务。 ECOSIRE 通过自动许可证配置和客户门户集成来处理生产中的 6 种 Stripe Webhook 事件类型。

如果您需要 Stripe 集成、订阅计费基础设施或全栈 SaaS 开发支持,请探索我们的开发服务

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天