PAYATHON 2026

如何实现可变金额和频率的 Recurring billing?

支付阿杰

结论

可以实现,但需要选择具备以下能力的支付服务商:

  • 托管式支付页面或安全的嵌入式支付组件
  • 卡信息 tokenization
  • 保存支付方式(card-on-file)
  • 服务端按需扣款
  • off-session / Merchant-Initiated Transaction(MIT)
  • Webhook 异步通知

这类需求不适合按固定周期、固定金额的传统订阅来设计。更合适的模式是:

客户首次授权保存支付方式,之后在余额低于阈值时,自动发起不定期、金额可变的充值。

Stripe、Adyen、Braintree、Checkout.com 等支付服务商通常都有类似能力。不过,它们支持的国家、币种、支付方式和强客户认证规则各不相同。接入前,需要根据商户注册地和业务地区核对服务商的最新文档。

推荐的实现流程

1. 创建支付服务商侧的客户对象

网站先在自己的数据库中创建客户,再调用支付服务商 API 创建对应的 customer。

建议保存清晰的映射关系:

local_customer_id -> provider_customer_id

不要只依赖前端传入的 customerID,因为前端参数可能被修改。最终的关联关系应由服务端创建并验证。

2. 创建“保存支付方式”会话

服务端创建一次性 setup session,再将客户重定向到支付服务商的 hosted page。这个页面只负责收集、验证和保存卡信息,不会立即扣款。

概念性代码如下:

const session = await PaymentProvider.createSetupSession({
  customer: providerCustomerId,
  success_url: "https://example.com/billing/success",
  cancel_url: "https://example.com/billing/cancel",
  metadata: {
    local_customer_id: localCustomerId
  }
});

return {
  checkout_url: session.url
};

local_customer_id 最好写入服务端创建的 metadata,不要完全依赖浏览器回传的数据。

3. 通过 Webhook 确认 tokenization

客户完成卡片登记后,支付服务商会通过 Webhook 通知网站。网站验证 Webhook 签名后,从事件中取得对应的支付方式标识。

不同服务商对这个标识的命名可能不同,例如:

  • payment_method_id
  • token
  • card_token
  • payment_instrument_id

收到事件后,建立以下映射:

local_customer_id
    -> provider_customer_id
    -> payment_method_id

示例:

app.post("/webhooks/payments", rawBodyMiddleware, async (req, res) => {
  const event = verifyWebhookSignature(
    req.rawBody,
    req.headers["payment-signature"]
  );

  if (event.type === "setup.completed") {
    const setup = event.data.object;

    await savePaymentMethod({
      localCustomerId: setup.metadata.local_customer_id,
      providerCustomerId: setup.customer,
      paymentMethodId: setup.payment_method
    });
  }

  res.sendStatus(200);
});

这里的事件名称和字段只是通用示意,接入时必须替换成所选支付服务商的实际 API。

不要因为客户进入了成功跳转页面,就认定卡片已经保存。浏览器可能被关闭,请求也可能重复或被伪造,最终状态应以 Webhook 为准。

4. 余额不足时发起自动充值

当 credits 低于设定阈值时,服务端使用已保存的支付方式发起 off-session 扣款:

const charge = await PaymentProvider.charge({
  customer: providerCustomerId,
  payment_method: paymentMethodId,
  currency: "USD",
  amount: 2000,
  off_session: true,
  metadata: {
    local_customer_id: localCustomerId,
    refill_id: refillId
  },
  idempotency_key: refillId
});

大多数支付 API 使用最小货币单位表示金额。例如:

USD 20.00 -> amount = 2000
JPY 2000  -> amount = 2000

不能假设所有币种都有两位小数,具体应遵循支付服务商提供的币种规则。

只有在支付服务商确认扣款成功后,系统才能增加 credits:

if (event.type === "payment.succeeded") {
  await database.transaction(async (tx) => {
    const firstProcessing = await tx.markPaymentSucceededOnce(
      event.data.object.id
    );

    if (firstProcessing) {
      await tx.addCredits(
        event.data.object.metadata.local_customer_id,
        calculateCredits(event.data.object.amount)
      );
    }
  });
}

充值结果同样应以 Webhook 为准,同时要保证重复通知不会导致 credits 被重复发放。

自动充值策略

可以允许客户配置:

余额低于:100 credits
自动购买:1000 credits
单次上限:USD 50
每月上限:USD 200

触发流程可以表示为:

消费 credits
    ↓
余额低于阈值
    ↓
创建唯一 refill 订单
    ↓
发起 off-session 扣款
    ↓
等待支付 Webhook
    ├─ 成功:增加 credits
    ├─ 需要认证:通知客户完成认证
    └─ 失败:暂停自动充值并通知客户

系统应设置单次、每日或每月充值上限,防止程序错误、并发请求或异常消费引发连续扣款。

PCI-DSS 边界

使用 hosted page 或支付服务商提供的安全组件,可以明显缩小 PCI-DSS 合规范围,因为卡号和 CVC 不会经过网站服务器。

这不代表商户完全不需要处理 PCI-DSS 合规工作。通常仍需要:

  • 完成适用的 PCI-DSS 自评问卷
  • 确保网站和跳转流程安全
  • 不记录卡号、CVC 或 hosted page 表单内容
  • 妥善管理 API 密钥和 Webhook 密钥
  • 确认所选集成模式对应的合规范围

如果自行托管信用卡输入框,即使卡信息随后立即完成 tokenization,也可能扩大合规范围。应优先使用支付服务商托管的页面,或由其官方提供、能将卡数据直接提交给服务商的安全组件。

强客户认证与扣款授权

在欧洲经济区等适用 SCA 的市场,客户第一次保存卡片时通常需要完成认证,并明确同意未来的自动充值。

后续扣款应标记为 off-session 或 MIT,但这不能保证每次扣款都能在无交互的情况下完成。发卡行仍可能要求客户再次认证,因此系统至少要处理以下三类结果:

succeeded
requires_customer_action
failed

如果交易需要客户操作,应暂停本次自动充值,并向客户发送链接,让其返回支付页面完成认证。

授权页面还应明确说明:

  • 自动充值的触发条件
  • 每次充值金额,或金额的确定方式
  • 扣款币种
  • 取消自动充值的方法
  • 单次及周期累计上限

如果每次充值的金额完全动态变化,需要特别确认当前授权方式是否符合支付服务商、卡组织规则和当地法规。

多币种限制

技术上,可以在每次请求中传入 currency 和 amount,但这并不意味着任意 token 都能以任意币种扣款。实际支持范围取决于:

  • 商户账户开通的结算币种
  • 支付服务商支持的交易币种
  • 商户所在国家或地区
  • 卡片和发卡行限制
  • 是否发生货币转换
  • 初次授权与后续扣款的币种约定

更稳妥的方式是让客户为自动充值选择一个固定币种,之后只调整充值金额。如果确实需要更改币种,应在首次授权文案中说明,并向支付服务商确认该场景适用的 MIT 规则。

其他重要注意事项

  • token 不是卡号,通常只能在创建它的支付服务商及商户账户中使用。
  • 不要把 token 视为公开且永久有效的数据,应将其作为敏感业务标识进行保护。
  • API 密钥只能保存在服务端,不能出现在浏览器代码中。
  • 所有创建扣款的请求都应使用 idempotency key。
  • Webhook 必须验证签名,并能安全处理乱序、延迟和重复事件。
  • credits 必须与支付订单建立不可变的账本记录,不能只更新一个余额字段。
  • 系统需要处理卡片过期、卡片替换、余额不足、风控拒付和 chargeback。
  • 客户关闭自动充值后,应停止发起新的扣款。是否删除已保存的支付方式,取决于其他支付用途和数据保留要求。
  • 销售 virtual credits 还可能涉及退款、未消费余额、税务和消费者权益规则,需要结合实际经营地区确认。

因此,这个目标接口在业务层面完全可以实现。更准确的抽象不是保存一个 tokenID 后随意调用 charge(),而是保存支付服务商的 customer 与 payment method 映射,并围绕授权、off-session 扣款、Webhook、幂等和账本构建完整的自动充值流程。

备注:内容仅供参考。