关于 payment-integration
payment-integration 是为 Web 和移动应用开发者提供的支付与订阅集成技能,重点介绍 Stripe,也涵盖 RevenueCat 移动订阅。内容包括结账、Webhook 幂等处理、订阅生命周期和按用量计费等实现模式。
主要功能
- 01Stripe Checkout 与 Elements 集成
- 02Webhook 签名验证与幂等处理
- 03订阅生命周期管理
- 04客户自助计费门户
- 05按用量计费模式
- 06RevenueCat 移动订阅
适用场景
- 01为应用添加支付与订阅
- 02处理支付状态和重复 Webhook 事件
- 03构建按用量计费方案
- 04集成移动应用内购
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ travisjneuman/ .claude --skill payment-integration安装后在 Claude Code 中输入/payment-integration确认已加载。
详细介绍
支付集成
概述
此技能涵盖在 Web 和移动应用中实现支付处理和订阅计费。内容涉及 Stripe 集成(Checkout、Elements、PaymentIntents)、订阅生命周期管理、带幂等性的 Webhook 处理、PCI 合规、计量和按用量计费、税费计算、退款处理,以及用于移动订阅的 RevenueCat。
在添加支付处理、构建订阅计费、处理 Stripe Webhook、实现定价页面、管理支付失败或集成移动应用内购买时,请使用此技能。
核心原则
- 切勿处理原始银行卡数据 - 使用 Stripe Elements 或 Checkout,让卡号完全不经过你的服务器。这样可以保持在 PCI SAQ-A(最简单的合规级别),而非 SAQ-D。
- Webhook 是事实来源 - 切勿信任客户端的支付确认。客户端上的 PaymentIntent 成功并不意味着什么,只有 Webhook 处理程序确认
payment_intent.succeeded后才算数。围绕 Webhook 事件构建系统。 - 处处实现幂等性 - Webhook 可能会被多次发送。每个 Webhook 处理程序都必须具备幂等性。使用 Stripe 的事件 ID 作为去重键。
- 妥善处理失败 - 支付可能因多种原因失败(余额不足、卡片过期、欺诈检测)。在订阅生命周期中构建重试流程、催款邮件和优雅降级机制。
- 使用 Stripe 的测试模式 - 使用测试 API 密钥、测试卡号和测试时钟来测试订阅生命周期。切勿使用真实支付方式进行测试。
关键模式
模式 1:使用 Stripe Checkout 进行订阅
适用场景: 希望 Stripe 处理整个结账界面,包括支付表单、优惠券代码和税费计算。
实现:
// Server - Create Checkout Session
// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
export async function POST(req: NextRequest) {
const { priceId, userId, email } = await req.json();
// Get or create Stripe customer
let customerId = await getStripeCustomerId(userId);
if (!customerId) {
const customer = await stripe.customers.create({
email,
metadata: { userId },
});
customerId = customer.id;
await saveStripeCustomerId(userId, customerId);
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
subscription_data: {
metadata: { userId },
trial_period_days: 14,
},
// Enable automatic tax calculation
automatic_tax: { enabled: true },
// Allow promo codes
allow_promotion_codes: true,
// Collect billing address for tax
billing_address_collection: "required",
// Customer portal for self-service management
customer_update: {
address: "auto",
name: "auto",
},
});
return NextResponse.json({ url: session.url });
}
// Client - Redirect to Checkout
function PricingCard({ plan }: { plan: PricingPlan }) {
const [loading, setLoading] = useState(false);
const handleSubscribe = async () => {
setLoading(true);
try {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
priceId: plan.stripePriceId,
userId: user.id,
email: user.email,
}),
});
const { url } = await res.json();
window.location.href = url; // Redirect to Stripe Checkout
} catch (error) {
toast.error("Failed to start checkout");
} finally {
setLoading(false);
}
};
return (
<div className="pricing-card">
<h3>{plan.name}</h3>
<p className="price">${plan.price}/mo</p>
<ul>
{plan.features.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
<button onClick={handleSubscribe} disabled={loading}>
{loading ? "Redirecting..." : "Subscribe"}
</button>
</div>
);
}
原因: Stripe Checkout 可处理 PCI 合规、3D Secure 身份验证、税费计算、促销代码和本地化。构建自己的结账表单需要数百小时的工作,还会带来持续的 PCI 合规负担。除非有充分理由构建自定义界面,否则请使用 Checkout。
模式 2:带幂等性的 Webhook 处理程序
适用场景: 任何 Stripe 集成。Webhook 是了解支付状态的唯一可靠方式。
实现:
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Process each event type
const eventHandlers: Record<string, (event: Stripe.Event) => Promise<void>> = {
"checkout.session.completed": async (event) => {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId ?? session.subscription?.toString();
await db.user.update({
where: { stripeCustomerId: session.customer as string },
data: {
subscriptionId: session.subscription as string,
subscriptionStatus: "active",
},
});
},
"customer.subscription.updated": async (event) => {
const subscription = event.data.object as Stripe.Subscription;
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: {
subscriptionStatus: subscription.status,
planId: subscription.items.data[0].price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
},
"customer.subscription.deleted": async (event) => {
const subscription = event.data.object as Stripe.Subscription;
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: {
subscriptionStatus: "canceled",
planId: null,
},
});
},
"invoice.payment_failed": async (event) => {
const invoice = event.data.object as Stripe.Invoice;
await db.user.update({
where: { stripeCustomerId: invoice.customer as string },
data: { subscriptionStatus: "past_due" },
});
// Send dunning email
await sendDunningEmail(invoice.customer as string, {
amountDue: invoice.amount_due,
nextRetry: invoice.next_payment_attempt
? new Date(invoice.next_payment_attempt * 1000)
: null,
});
},
"invoice.paid": async (event) => {
const invoice = event.data.object as Stripe.Invoice;
await db.user.update({
where: { stripeCustomerId: invoice.customer as string },
data: { subscriptionStatus: "active" },
});
},
};
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
// 1. Verify webhook signature (prevents spoofing)
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// 2. Idempotency check - skip already-processed events
const alreadyProcessed = await db.stripeEvent.findUnique({
where: { eventId: event.id },
});
if (alreadyProcessed) {
return NextResponse.json({ received: true });
}
// 3. Process the event
const handler = eventHandlers[event.type];
if (handler) {
try {
await handler(event);
// 4. Record processed event for idempotency
await db.stripeEvent.create({
data: {
eventId: event.id,
type: event.type,
processedAt: new Date(),
},
});
} catch (err) {
console.error(`Error processing ${event.type}:`, err);
// Return 500 so Stripe retries
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
}
}
return NextResponse.json({ received: true });
}
原因: Stripe 至少会发送一次 Webhook,因此可能出现重复。幂等性检查(存储已处理事件 ID)可防止重复处理。签名验证可防止伪造 Webhook 攻击。处理程序出错时返回 500,会触发 Stripe 以指数退避方式自动重试。
模式 3:用于自助服务的 Customer Portal
适用场景: 让客户自行管理订阅(升级、降级、取消、更新支付方式),无需联系支持人员。
实现:
// Create portal session
// app/api/billing/portal/route.ts
export async function POST(req: NextRequest) {
const { userId } = await req.json();
const user = await db.user.findUniqueOrThrow({
where: { id: userId },
select: { stripeCustomerId: true },
});
if (!user.stripeCustomerId) {
return NextResponse.json({ error: "No billing account" }, { status: 400 });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_URL}/settings/billing`,
});
return NextResponse.json({ url: session.url });
}
// Configure the portal in Stripe (do this once, via API or Dashboard)
await stripe.billingPortal.configurations.create({
business_profile: {
headline: "Manage your subscription",
},
features: {
subscription_update: {
enabled: true,
default_allowed_updates: ["price", "quantity"],
proration_behavior: "create_prorations",
products: [
{
product: "prod_xxx",
prices: ["price_monthly", "price_annual"],
},
],
},
subscription_cancel: {
enabled: true,
mode: "at_period_end", // Don't cancel immediately
cancellation_reason: {
enabled: true,
options: [
"too_expensive",
"missing_features",
"switched_service",
"unused",
"other",
],
},
},
payment_method_update: { enabled: true },
invoice_history: { enabled: true },
},
});
原因: 自助计费可减少 60-80% 的支持工单。Stripe 的 Customer Portal 是一种托管解决方案,可处理套餐变更、按比例计费、收集取消原因、更新支付方式和查看发票历史记录——所有这些都无需构建自定义界面。
模式 4:按用量计费
适用场景: 定价依据消耗量(API 调用、存储空间、计算分钟数、席位数),而非固定费率订阅。
实现:
// Report usage to Stripe
async function reportUsage(
subscriptionItemId: string,
quantity: number,
timestamp?: number
): Promise<void> {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: timestamp ?? Math.floor(Date.now() / 1000),
action: "increment", // Add to existing usage (vs "set" to replace)
});
}
// Background job: aggregate and report usage hourly
async function reportHourlyUsage(): Promise<void> {
const activeSubscriptions = await db.subscription.findMany({
where: { status: "active", plan: { billingModel: "usage" } },
include: { user: true },
});
for (const sub of activeSubscriptions) {
const usage = await getHourlyUsage(sub.userId);
if (usage > 0) {
await reportUsage(sub.stripeSubscriptionItemId, usage);
await db.usageReport.create({
data: {
subscriptionId: sub.id,
quantity: usage,
reportedAt: new Date(),
},
});
}
}
}
// Track usage in your application
async function trackApiCall(userId: string, endpoint: string): Promise<void> {
// Increment usage counter (Redis for speed)
const key = `usage:${userId}:${getCurrentHour()}`;
await redis.incr(key);
await redis.expire(key, 86400 * 7); // Keep for 7 days
// Check if user is approaching their limit
const currentUsage = await getCurrentMonthUsage(userId);
const limit = await getUserPlanLimit(userId);
if (currentUsage >= limit * 0.8) {
await sendUsageWarningEmail(userId, currentUsage, limit);
}
if (currentUsage >= limit) {
throw new UsageLimitExceededError(userId, currentUsage, limit);
}
}
原因: 按用量计费可让费用与价值相匹配——客户按实际用量付费。按小时汇总可减少对 Stripe 的 API 调用,同时确保用量数据足够新,可用于开具发票。基于 Redis 的跟踪可为速率限制提供亚毫秒级的用量检查。
Stripe 测试卡参考
| 卡号 | 场景 |
|---|---|
4242 4242 4242 4242 | 支付成功 |
4000 0000 0000 3220 | 需要 3D Secure 验证 |
4000 0000 0000 9995 | 支付被拒(余额不足) |
4000 0000 0000 0341 | 添加到客户失败 |
4000 0025 0000 3155 | 需要进行身份验证 |
使用任意未来的到期日期和任意 3 位 CVC。
反模式
| 反模式 | 问题所在 | 更好的做法 |
|---|---|---|
| 信任客户端的支付确认 | 用户可以伪造成功状态 | 以 Webhook 处理程序为准 |
| 在服务器上处理原始卡号 | 需要符合 PCI SAQ-D(成本非常高) | 使用 Stripe Elements 或 Checkout |
| Webhook 处理程序没有幂等性 | 可能重复扣款、重复开通服务 | 根据事件 ID 去重 |
| 立即取消订阅 | 用户会在计费周期结束前失去访问权限 | 在周期结束时取消(cancel_at_period_end) |
| 在应用中硬编码价格 | 无法在不部署的情况下更改价格 | 将价格 ID 存储在数据库或环境变量中 |
| 没有针对支付失败的催款流程 | 收入悄然流失(非自愿流失) | 自动重试 + 催款邮件 |
| 使用真实支付方式进行测试 | 可能产生真实扣款,且无法使用测试时钟 | 仅使用 Stripe 测试模式 |
检查清单
- Stripe API 密钥存储在环境变量中(绝不写入代码)
- 已启用 Webhook 签名验证
- Webhook 处理程序具有幂等性(按事件 ID 去重)
- 已处理所有订阅状态变更(已创建、已更新、已取消、逾期未付)
- 已实现支付失败催款流程(邮件 + 重试)
- 已配置客户门户,以便用户自助管理账单
- 所有开发和预发布环境均使用测试模式
- 已确认 PCI 合规级别(使用 Stripe Checkout/Elements 时为 SAQ-A)
- 已启用税费计算(Stripe Tax 或外部服务商)
- 已为套餐变更配置按比例计费(升级/降级)
- 已在 Stripe Dashboard 中注册 Webhook 端点
- 已安装 Stripe CLI,以便在本地测试 Webhook(
stripe listen --forward-to)
相关资源
- 技能:
authentication-patterns(用于账单的用户身份验证)、email-systems(催款邮件) - 技能:
monitoring-observability(支付失败告警) - 规则:
docs/reference/stacks/fullstack-nextjs-nestjs.md(API 路由模式)