Stripe Billing 实施:订阅、发票和 Webhooks
Stripe 是计费基础设施事实上的标准,但正确实施它(幂等地处理 Webhook、管理订阅状态转换以及从失败的支付中恢复)比入门文档建议的要小心得多。一个支付系统在 99% 的时间里都能正常工作,而在另外 1% 的时间里默默地失败,比在 95% 的情况下大声失败的支付系统更糟糕。
本指南涵盖了具有 6 个 Webhook 事件类型的生产 Stripe 实现、订阅生命周期管理、自动许可证配置和退款处理。代码示例使用 NestJS 11,但这些模式适用于任何后端框架。
要点
- 切勿根据结帐重定向提供访问权限 - 始终等待
checkout.session.completedwebhook- 在处理任何事件之前使用
stripe.webhooks.constructEvent()验证 Webhook 签名- 使所有 Webhook 处理程序幂等 - Stripe 重试失败的 Webhook 长达 72 小时
- 将 Stripe 客户 ID、订阅 ID 和价格 ID 存储在数据库中 — 不要重新查询 Stripe 的所有信息
- 处理
invoice.payment_failed以触发催款电子邮件和订阅暂停charge.refunded和checkout.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_id、stripe_subscription_id 和 stripe_payment_intent_id 上创建索引 — 您将经常通过这些索引查找记录。在适当的情况下添加唯一约束(每个用户一条客户记录)。切勿将信用卡号或原始付款数据存储在您自己的数据库中。
后续步骤
正确实施 Stripe 计费(通过 Webhook 幂等性、订阅生命周期管理、自动配置和正确的退款处理)是一项艰巨的工程任务。 ECOSIRE 通过自动许可证配置和客户门户集成来处理生产中的 6 种 Stripe Webhook 事件类型。
如果您需要 Stripe 集成、订阅计费基础设施或全栈 SaaS 开发支持,请探索我们的开发服务。
作者
ECOSIRE Research and Development Team
在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。
相关文章
ERP for Law Firms: Case Management, Billing, and Client Portal
Complete guide to ERP for law firms — case management, legal billing, trust accounting, IOLTA compliance, client portal, and matter management for 2026.
Media Company ERP Implementation: Editorial, Ad Sales, and Subscriptions
Step-by-step guide to implementing ERP in media companies, covering editorial workflow integration, advertising system migration, subscription billing setup, and data migration.
Professional Services ERP Implementation: Time, Billing, and Projects
Step-by-step professional services ERP implementation guide covering time tracking, billing configuration, project accounting, and resource management setup.