如何选择简单的 ondemand billing provider?
结论
支付服务商至少需要支持这些能力:
- 托管式或嵌入式信用卡收集页面
- 支付方式 tokenization
- 首次购买使用
PaymentIntent一类的一次性支付接口 - 绑卡使用
SetupIntent一类的授权接口 - 后台发起 off-session payment
- Webhook 通知和幂等请求
- 企业发票、税务及对账功能
常见选择有 Stripe、Adyen 和 Braintree。若想尽快上线,可以先评估 Stripe。交易规模较大、覆盖多个国家,或者需要更复杂的企业支付能力时,也可以同时评估 Adyen。最终采用哪家服务商,还要看业务所在国家、结算币种、虚拟积分的用途和服务商的最新条款。
需要注意的是,把卡片输入交给支付服务商,能够显著缩小 PCI DSS 范围,但不代表完全免除 PCI 义务。商户通常仍要完成相应的自评问卷,并确保信用卡数据从不经过自己的服务器。
推荐的系统设计
支付服务商不应直接维护积分余额。可以按以下方式划分职责:
- 支付服务商负责收款、保存支付方式和处理退款。
- 业务系统负责客户账户、积分余额和积分流水。
- Webhook 将支付结果可靠地同步到业务系统。
一次性购买的典型流程如下:
客户发起购买
↓
后端创建支付订单
↓
前端通过服务商托管组件收集卡片并确认支付
↓
服务商发送 payment_succeeded Webhook
↓
后端验证签名并增加积分
自动充值流程如下:
积分余额低于阈值
↓
检查客户是否已授权自动充值
↓
后端使用已保存的 payment_method 发起 off-session payment
↓
支付成功后增加积分
↓
失败则通知客户重新验证或更换信用卡
只有收到可信的支付成功事件后,才能将积分记入账户。前端跳转成功或客户端返回的结果不能作为最终依据。
一次性购买积分
后端先创建本地订单,再调用支付服务商创建支付请求。下面是基于 Stripe 风格接口的示意代码,实际参数以当前 SDK 文档为准:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function createCreditPurchase(customerId, packageId) {
const packageInfo = await getCreditPackage(packageId);
const order = await createPendingOrder({
customerId,
packageId,
credits: packageInfo.credits,
amount: packageInfo.amount,
currency: packageInfo.currency
});
const paymentIntent = await stripe.paymentIntents.create(
{
amount: packageInfo.amount,
currency: packageInfo.currency,
customer: customerId,
automatic_payment_methods: {
enabled: true
},
metadata: {
order_id: order.id,
credits: String(packageInfo.credits)
}
},
{
idempotencyKey: `credit-purchase:${order.id}`
}
);
return {
orderId: order.id,
clientSecret: paymentIntent.client_secret
};
}
金额必须由后端根据固定套餐计算,不能采用浏览器提交的金额或积分数量。金额通常使用最小货币单位。例如,1000 可能表示 10.00,具体规则取决于币种和支付服务商。
登记信用卡并授权自动充值
不要通过自己的表单采集完整卡号。绑卡时应建立专门的支付方式保存和授权流程:
async function createCardSetup(customerId) {
const setupIntent = await stripe.setupIntents.create({
customer: customerId,
usage: "off_session",
payment_method_types: ["card"],
metadata: {
purpose: "automatic_credit_top_up"
}
});
return {
clientSecret: setupIntent.client_secret
};
}
前端通过服务商提供的托管组件录入卡片,并完成必要的身份验证。业务数据库只保存以下非敏感标识:
{
"customer_id": "cus_example",
"payment_method_id": "pm_example",
"auto_top_up_enabled": true,
"threshold_credits": 100,
"top_up_credits": 1000,
"top_up_amount": 5000,
"currency": "usd"
}
完整卡号、CVC 和磁道数据都不能保存。即便用于日志排错,也不应记录支付组件提交的敏感字段。
自动充值实现
积分即将耗尽时,后端可以尝试通过已保存的支付方式扣款:
async function runAutomaticTopUp(account) {
if (!account.autoTopUpEnabled) {
return;
}
if (account.creditBalance >= account.thresholdCredits) {
return;
}
const topUp = await createPendingTopUp(account);
try {
const paymentIntent = await stripe.paymentIntents.create(
{
amount: account.topUpAmount,
currency: account.currency,
customer: account.providerCustomerId,
payment_method: account.providerPaymentMethodId,
off_session: true,
confirm: true,
metadata: {
top_up_id: topUp.id,
account_id: account.id,
credits: String(account.topUpCredits)
}
},
{
idempotencyKey: `automatic-top-up:${topUp.id}`
}
);
await recordPaymentSubmitted(topUp.id, paymentIntent.id);
} catch (error) {
await markTopUpAttemptFailed(topUp.id, error.code);
await notifyCustomerToUpdatePaymentMethod(account.id);
}
}
后台扣款不一定每次都能直接成功。银行可能要求客户完成 3-D Secure 等验证。遇到这种情况,应把充值记录标记为”需要客户操作”,再向客户发送安全链接,让其返回网站完成验证。扣款失败后不要无限重试,否则可能造成重复扣款或触发风控。
Webhook 与积分入账
积分发放应由 Webhook 驱动,同时完成签名校验、事务处理和幂等控制:
app.post(
"/webhooks/payments",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch {
return res.sendStatus(400);
}
if (event.type === "payment_intent.succeeded") {
const payment = event.data.object;
const referenceId =
payment.metadata.order_id || payment.metadata.top_up_id;
await database.transaction(async (tx) => {
const alreadyProcessed = await tx.hasProcessedPayment(payment.id);
if (alreadyProcessed) {
return;
}
await tx.addCreditLedgerEntry({
referenceId,
paymentId: payment.id,
credits: Number(payment.metadata.credits),
type: "purchase"
});
await tx.markPaymentProcessed(payment.id);
});
}
res.sendStatus(200);
}
);
生产系统还要处理退款、撤销、争议和异步失败事件。产品条款中应明确,退款后是否收回尚未使用的积分。需要收回时,应通过积分流水冲正,不能直接覆盖余额。
嵌入网站时如何控制 PCI 范围
如果要在 popup 中显示支付界面,应使用服务商官方提供的 embedded checkout、hosted fields 或安全支付组件,不要随意把普通托管结账 URL 放进自建 iframe。
原因包括:
- 某些托管页面会通过
X-Frame-Options或 CSP 禁止第三方 iframe。 - 自己控制卡片输入框可能扩大 PCI DSS 范围。
- 3-D Secure、银行跳转和数字钱包可能需要弹窗或顶层导航。
- iframe 仍然需要防范脚本注入和页面篡改。
要尽量缩小合规范围,所有信用卡输入字段都应由支付服务商托管,卡片数据直接发送给服务商。自己的服务器只接收 token、payment_method_id 或支付结果标识。具体适用 SAQ A 还是其他范围,要根据实际集成方式和收单机构的要求确认。
选择服务商时重点比较
除了接入难度,还要核实以下事项:
- 是否支持商户注册所在地及客户所在国家
- 是否允许销售这种虚拟积分
- 积分是否只能用于自有服务,能否转让或兑换现金
- 是否支持所需币种、税费、企业发票和采购订单号
- 是否支持保存支付方式及 off-session payment
- 是否提供 3-D Secure/SCA 处理流程
- 是否能在 iframe 或嵌入式组件中完成支付
- Webhook 是否支持签名、重放和事件查询
- 退款、拒付、风控及失败重试如何收费
- 是否允许在账单描述中显示公司名称
- 是否支持多个企业用户共用一个计费账户
如果积分可以转让、提现、兑换第三方商品或长期储值,它可能超出普通站内消费额度的范畴,涉及储值、电子货币或其他监管问题。不能只凭支付接口是否支持来判断。应先请支付服务商确认业务模型,并根据经营地区咨询合规专业人士。
实施时容易忽略的问题
启用自动充值前,需要取得明确授权,并向客户展示充值阈值、单次金额、币种、触发条件、取消方法和退款规则。企业客户最好还能设置月度充值上限,并允许账单联系人接收支付通知。
积分账本建议采用不可变流水,不要只保存一个余额:
+1000 购买积分
-35 API 使用
-20 API 使用
-945 退款冲正
还要通过数据库锁或唯一约束控制自动充值,避免多个并发请求同时发现余额不足并分别扣款。幂等键只能防止相同请求被重复提交,不能取代业务层的并发控制。
如果客户主要是企业,还应评估信用卡以外的支付方式,例如预付发票、银行转账、ACH/SEPA Direct Debit 或月结账单。信用卡自动充值适合快速启用和中小额消费,大额企业采购则往往更看重发票、付款期限、采购审批和对账信息。
备注:内容仅供参考。
