PAYATHON 2026

如何实现信用卡按月自动订阅计费?

支付小周

结论

最省事也更可靠的方案,是接入支持周期订阅的支付服务,例如 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

订阅流程如下:

  1. 用户注册并选择套餐。
  2. 后端根据套餐找到对应的 price_id。
  3. 后端创建订阅结账会话。
  4. 用户在支付平台托管的页面中填写信用卡并完成付款。
  5. 支付平台创建客户和订阅,之后每月自动生成账单并尝试扣款。
  6. 支付平台通过 Webhook 把支付结果通知给应用。
  7. 应用根据订阅状态决定用户能否使用付费功能。
  8. 用户取消订阅时,可以设置为当前周期结束后取消,也可以立即取消。

信用卡信息由支付平台处理。你的数据库只需保存客户编号、订阅编号、套餐和订阅状态等信息。

创建按月订阅

下面的示例使用 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 + 客户门户”通常就足够了,无需自行开发信用卡保管、定时扣款和催款系统。

备注:内容仅供参考。