如何实现可变金额和频率的 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_idtokencard_tokenpayment_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、幂等和账本构建完整的自动充值流程。
备注:内容仅供参考。
