如何实现信用卡按月自动订阅计费?
结论
最省事也更可靠的方案,是接入支持周期订阅的支付服务,例如 Stripe Billing、Paddle、Braintree 或 Adyen。支付平台负责保存信用卡信息、生成月度账单和自动扣款;应用只需处理套餐选择、订阅创建和 Webhook,并根据订阅状态开放或关闭服务。
不要自行保存卡号,也不要通过定时任务每月直接调用信用卡扣款接口。自己处理卡片数据,会大幅增加 PCI DSS 合规、安全防护、失败重试和身份验证的复杂度。
以下以 Stripe Checkout 和 Stripe Billing 为例。各地区可用的服务、支付方式和费率可能不同,接入前应确认目标国家或地区是否在支持范围内。
整体流程
可以在支付平台中为三个套餐分别创建按月循环的价格:
| 套餐 | 月费 | 平台价格标识示例 |
|---|---|---|
| Basic | ¥49/月 | price_basic_monthly |
| Pro | ¥99/月 | price_pro_monthly |
| Business | ¥199/月 | price_business_monthly |
订阅流程如下:
- 用户注册并选择套餐。
- 后端根据套餐找到对应的
price_id。 - 后端创建订阅结账会话。
- 用户在支付平台托管的页面中填写信用卡并完成付款。
- 支付平台创建客户和订阅,之后每月自动生成账单并尝试扣款。
- 支付平台通过 Webhook 把支付结果通知给应用。
- 应用根据订阅状态决定用户能否使用付费功能。
- 用户取消订阅时,可以设置为当前周期结束后取消,也可以立即取消。
信用卡信息由支付平台处理。你的数据库只需保存客户编号、订阅编号、套餐和订阅状态等信息。
创建按月订阅
下面的示例使用 Node.js、Express 和 Stripe SDK。环境变量、域名和价格 ID 都要替换为实际配置。
npm install express stripe
import express from "express";
import Stripe from "stripe";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const PLAN_PRICE_IDS = {
basic: process.env.STRIPE_PRICE_BASIC,
pro: process.env.STRIPE_PRICE_PRO,
business: process.env.STRIPE_PRICE_BUSINESS,
};
app.use("/webhooks/stripe", express.raw({ type: "application/json" }));
app.use(express.json());
app.post("/api/subscriptions/checkout", async (req, res) => {
const user = req.user; // 应由登录中间件提供
const { plan } = req.body;
const priceId = PLAN_PRICE_IDS[plan];
if (!priceId) {
return res.status(400).json({ error: "Invalid plan" });
}
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [
{
price: priceId,
quantity: 1,
},
],
customer_email: user.email,
client_reference_id: String(user.id),
metadata: {
userId: String(user.id),
plan,
},
subscription_data: {
metadata: {
userId: String(user.id),
plan,
},
},
success_url:
"https://example.com/billing/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://example.com/pricing",
});
res.json({ checkoutUrl: session.url });
});
app.listen(3000);
前端收到 checkoutUrl 后直接跳转:
const response = await fetch("/api/subscriptions/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ plan: "pro" }),
});
const { checkoutUrl } = await response.json();
window.location.href = checkoutUrl;
后端不能直接采用前端传入的金额。前端只应提交套餐代码,实际价格必须由后端维护的套餐映射或数据库确定,否则攻击者可能篡改请求金额。
使用 Webhook 同步订阅状态
用户进入支付成功页面,不代表付款一定成功。浏览器可能中途关闭,跳转请求也可能丢失,部分支付还需要异步完成。因此,应用应以支付平台发送的 Webhook 为准来同步订阅状态。
app.post("/webhooks/stripe", async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (error) {
return res.status(400).send(`Webhook Error: ${error.message}`);
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
await saveSubscriptionIdentifiers({
userId: session.client_reference_id,
customerId: session.customer,
subscriptionId: session.subscription,
});
break;
}
case "invoice.paid": {
const invoice = event.data.object;
await markSubscriptionActive({
customerId: invoice.customer,
subscriptionId: invoice.subscription,
});
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object;
await markSubscriptionPastDue({
customerId: invoice.customer,
subscriptionId: invoice.subscription,
});
break;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object;
await syncSubscription({
subscriptionId: subscription.id,
customerId: subscription.customer,
status: subscription.status,
priceId: subscription.items.data[0]?.price?.id,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
currentPeriodEnd:
subscription.items.data[0]?.current_period_end ?? null,
});
break;
}
}
res.sendStatus(200);
});
Webhook 路由必须接收原始请求体,否则签名验证可能失败。还需要注意以下几点:
- 验证 Webhook 签名,不能只相信请求中的内容。
- 保存事件 ID,防止同一事件被重复处理。
- 支持事件乱序到达,不要依赖固定的接收顺序。
- 尽快返回成功响应,把耗时操作放入队列执行。
- 保证失败后可以安全重试,数据库更新应具备幂等性。
- 定期向支付平台重新查询订阅,修复偶发的状态不同步。
数据库应保存什么
可以为每位用户保存一条订阅记录:
user_id
provider
provider_customer_id
provider_subscription_id
plan
status
cancel_at_period_end
current_period_end
created_at
updated_at
不要保存完整卡号、CVC 或磁条数据。如果页面需要展示支付方式,可以保存支付平台返回的非敏感信息,例如卡品牌、末四位数字和到期月份。
判断用户权限时,也不能只检查订阅记录是否存在。更稳妥的做法是按照业务规则检查订阅状态和有效期。例如,active 状态通常可以继续使用服务;past_due 是否立即停用,可以由催款策略决定;订阅已经 canceled,但付费周期尚未结束时,用户通常仍可继续使用。
取消订阅
常见的处理方式是在当前计费周期结束后取消:
app.post("/api/subscriptions/cancel", async (req, res) => {
const user = req.user;
const subscriptionId = await getSubscriptionIdForUser(user.id);
if (!subscriptionId) {
return res.status(404).json({ error: "Subscription not found" });
}
const subscription = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
res.json({
subscriptionId: subscription.id,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
});
});
设置后,订阅不会在下个周期继续续费,用户通常可以使用服务直至已付费周期结束。
如果业务要求立即取消,可以调用立即删除订阅的接口,但要提前确定退款、按比例计费和剩余权益的处理方式。不要只在本地数据库中把状态改为”已取消”,否则支付平台下个月仍可能继续扣款。
也可以使用支付平台提供的客户门户,让用户自行更换信用卡、下载账单、切换套餐或取消订阅:
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: "https://example.com/account/billing",
});
套餐升级与降级
变更套餐时,应修改现有订阅,不要为同一用户新建另一份订阅,否则两个套餐可能同时扣款。
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const subscriptionItem = subscription.items.data[0];
await stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscriptionItem.id,
price: newPriceId,
},
],
proration_behavior: "create_prorations",
});
proration_behavior 用于控制是否计算按比例差价。升级立即生效、降级从下个周期生效,通常更符合用户预期。具体规则仍要按照产品政策设置,并在确认页面明确显示本次扣款金额和下次续费日期。
必须处理的异常情况
自动订阅并不保证每个月都能成功扣款。实际运行中,需要处理这些情况:
- 信用卡过期、余额不足或被银行拒绝;
- 需要 3D Secure 或其他额外身份验证;
- 用户发起拒付或争议;
- 支付平台自动重试后仍然失败;
- 用户更换套餐时产生按比例费用;
- 优惠券、试用期、税费和退款;
- Webhook 重复、延迟或乱序;
- 用户重复点击结账按钮,产生多份订阅。
创建结账会话或订阅时,可以使用幂等键或业务侧的唯一约束来防止重复提交。除非业务明确允许,否则同一账户也不应同时拥有多份有效订阅。
扣款失败后,不建议立即永久删除用户数据。可以设置宽限期,先把状态标记为 past_due,并提醒用户更新支付方式。如果超过宽限期仍未付款,再暂停付费功能。
安全与合规注意事项
即使采用托管结账页面,仍要做好基本的安全控制:
- 密钥只能保存在服务端环境变量或密钥管理服务中。
- 前端只能使用公开密钥,不能暴露服务端密钥。
- 所有支付和账户页面都要使用 HTTPS。
- Webhook 必须验证签名。
- 套餐与价格的映射必须由后端决定。
- 记录支付状态变化,但不要把敏感卡片数据写入日志。
- 清楚展示价格、计费周期、自动续费规则和取消方式。
- 根据经营地区处理税务、发票、退款、隐私和自动续费法规。
托管支付页面通常可以缩小 PCI DSS 的合规范围,但不等于完全免除合规责任。具体要求取决于集成方式、业务所在地和支付流程,应以支付服务商及当地法规的最新要求为准。
对于三个固定的月付套餐,使用”托管结账页面 + 订阅计费 + Webhook + 客户门户”通常就足够了,无需自行开发信用卡保管、定时扣款和催款系统。
备注:内容仅供参考。
