如何实现Automatic Credit Card payment authorization?
结论
不要在自己的数据库中保存信用卡号或 CCV/CVV,也不要在续费时模拟用户再次提交卡片信息。正确的做法是接入符合 PCI DSS 要求的支付服务商,由服务商托管卡片信息采集,并返回不可逆的支付方式标识,例如 payment_method_id 或 token。
首次付款时,用户需要明确同意年度自动续费,并完成必要的持卡人验证。此后,服务端每年使用已保存的支付方式标识发起商户主动交易,也就是 Merchant-Initiated Transaction(MIT)。续费时通常不必再次输入卡号和 CCV,但交易是否成功仍取决于发卡行、当地法规和风控结果。
为什么不能直接保存卡号和 CCV
信用卡号是敏感支付数据。商户一旦自行保存、传输或处理完整卡号,PCI DSS 合规范围会明显扩大,数据泄露风险也会随之增加。
CCV/CVV 属于敏感认证数据。即使用户同意,商户也不应在授权完成后继续保存。自动续费应依靠支付服务商保存的卡片凭据和返回的 token,而不是保存 CCV。
不同数据的处理方式如下:
| 数据 | 建议 |
|---|---|
| 完整信用卡号 | 不在商户系统保存 |
| CCV/CVV | 授权完成后不得保存 |
| 支付方式 token/ID | 可以保存,用于后续扣款 |
| 卡片品牌和末四位 | 可按业务需要保存,用于向用户展示 |
| 到期月份和年份 | 可谨慎保存,或按需从支付服务商查询 |
| 用户授权记录 | 必须保存 |
| 支付服务商交易 ID | 必须保存,便于对账和退款 |
token 并不是加密后的卡号。正常情况下,商户无法通过 token 还原完整卡号,而且 token 通常只能在指定的支付服务商或商户账户中使用。
推荐的实现流程
1. 选择支持 recurring payment 的支付服务商
服务商至少需要支持:
- 托管支付页面、Hosted Fields 或官方前端支付组件
- 保存支付方式供未来使用
- Customer 与 Payment Method 管理
- off-session 或 merchant-initiated payment
- Webhook 异步通知
- 退款、争议和对账
- 目标经营地区要求的持卡人验证,例如 3-D Secure
尽量让信用卡信息直接从浏览器提交给支付服务商,不要先经过自己的服务器。这样可以减少业务系统接触敏感卡片数据的机会,但仍需进行相应的 PCI DSS 合规评估。
2. 明确取得用户授权
用户勾选自动续费时,页面应清楚说明:
- 收款商户名称
- 扣款金额,或者金额的计算方式
- 扣款周期,例如每 12 个月一次
- 首次扣款日期和预计续费日期
- 是否可能调整价格
- 取消自动续费的方法及截止时间
- 扣款失败后的重试规则
- 退款规则和联系方式
不要默认勾选自动续费,也不要使用含糊表述或隐藏条款。服务端应记录授权时间、条款版本、用户账号、关联的支付方式标识和必要的审计信息。授权记录不能只留在前端页面中。
3. 完成首次客户发起交易
首次绑定卡片或付款属于 Customer-Initiated Transaction(CIT)。此时用户仍在会话中,需要输入卡片信息,并按照支付服务商的要求完成 CCV、3-D Secure 或其他验证。
首次交易还应向支付服务商声明,这个支付方式以后会用于 off-session recurring payment。具体参数名称因服务商而异,应以当前接入服务商的文档为准。
如果业务会在首次操作时收费,可以在首次付款成功后保存支付方式。如果暂时不收费,应使用支付服务商提供的“设置支付方式”或“验证卡片”流程,不要自行采用小额扣款后退款的方式。
4. 只保存支付方式引用
商户数据库可以采用类似结构:
CREATE TABLE billing_authorizations (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
provider_customer_id VARCHAR(255) NOT NULL,
payment_method_id VARCHAR(255) NOT NULL,
card_brand VARCHAR(32),
card_last4 CHAR(4),
consent_version VARCHAR(64) NOT NULL,
consented_at TIMESTAMP NOT NULL,
next_charge_at TIMESTAMP NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
这里的 payment_method_id 是支付服务商生成的标识,不是信用卡号。数据库中不应增加 card_number、cvv 或类似字段。
5. 到期后由服务端发起续费
年度续费任务应在可信的服务端运行。下面是与具体服务商无关的示意代码:
async function renewSubscription(subscription) {
const invoice = await createPendingInvoice({
subscriptionId: subscription.id,
amount: subscription.renewalAmount,
currency: subscription.currency
});
try {
const payment = await paymentProvider.createPayment({
customerId: subscription.providerCustomerId,
paymentMethodId: subscription.paymentMethodId,
amount: invoice.amount,
currency: invoice.currency,
offSession: true,
recurring: true,
idempotencyKey: `annual-renewal:${invoice.id}`
});
await saveProviderPaymentId(invoice.id, payment.id);
// 最终状态应以支付服务商的 Webhook 为准。
return payment;
} catch (error) {
await markInvoiceForReview(invoice.id, error.code);
throw error;
}
}
offSession、recurring 和 idempotencyKey 只是概念性名称,不一定与某家支付服务商的实际参数完全一致。正式接入时,需要按照服务商当前的 API 文档进行替换。
同一张账单必须始终使用相同的幂等键。这样即使定时任务重跑、网络超时或服务重启,也能避免重复扣款。
Webhook 才是支付结果的主要依据
支付 API 返回“已接收”,不一定表示资金已经到账或交易最终成功。部分支付方式和风控流程采用异步处理,因此订单状态应根据通过签名验证的 Webhook 更新。
app.post(
"/webhooks/payment",
rawBodyMiddleware,
async (req, res) => {
let event;
try {
event = paymentProvider.verifyWebhook({
rawBody: req.body,
signature: req.headers["payment-signature"]
});
} catch {
return res.status(400).send("Invalid signature");
}
// event.id 必须有唯一约束,防止重复处理同一事件。
if (await webhookAlreadyProcessed(event.id)) {
return res.sendStatus(200);
}
await database.transaction(async (tx) => {
await tx.insertWebhookEvent(event.id, event.type);
switch (event.type) {
case "payment.succeeded":
await tx.markInvoicePaid(
event.data.invoiceId,
event.data.paymentId
);
break;
case "payment.failed":
await tx.markInvoicePaymentFailed(
event.data.invoiceId,
event.data.failureCode
);
break;
case "payment.requires_action":
await tx.markInvoiceActionRequired(event.data.invoiceId);
break;
}
});
return res.sendStatus(200);
}
);
Webhook 端点应满足以下要求:
- 使用原始请求体验证签名
- 拒绝无法验证的事件
- 对事件 ID 建立唯一约束
- 能够处理重复投递和乱序投递
- 不在返回响应前执行耗时操作
- 不根据前端跳转页面直接判断付款成功
代码中的事件名称也只是示例,接入时应替换成支付服务商实际定义的事件类型。
处理需要用户再次验证的情况
自动付款并不保证每次都能在没有用户操作的情况下成功。以下情况可能要求用户重新操作:
- 发卡行拒绝交易
- 卡片过期、注销或更换
- 余额或额度不足
- 风控系统要求额外认证
- 当地法规要求 Strong Customer Authentication
- 首次授权时没有正确声明未来用途
- 扣款金额或模式与原授权差异过大
当支付服务商返回 requires_action 一类状态时,不要绕过认证或反复尝试强制扣款。应将账单标记为待处理,并向用户发送安全链接,让用户登录后完成验证或更换支付方式。
一笔交易能否认定为 MIT,以及当次交易是否可以免于交互认证,取决于支付服务商、收单行、卡组织规则和经营地区。商户只能正确提交交易属性,不能保证豁免一定生效。
续费通知与失败重试
年度扣款间隔较长,用户可能已经忘记此前的授权。建议在扣款前发送通知,价格发生变化时尤其需要提前说明。通知中应包含扣款金额、日期、服务内容和取消入口。
失败后应限制重试次数,并逐步拉长重试间隔,例如:
首次失败:立即记录,不重复提交
第 1 次重试:3 天后
第 2 次重试:7 天后
最终失败:暂停续费并通知用户更新支付方式
具体间隔应结合支付服务商的建议、业务风险和当地法规确定。不要在短时间内高频重试,否则可能增加手续费、触发风控,甚至被认定为不当扣款。
如果支付服务商支持 Account Updater 或 network token,用户换卡或卡片到期后,部分支付凭据可能自动更新。但这不能保证扣款成功,系统仍需提供更换支付方式的流程。
安全和合规检查清单
上线前至少应确认:
- 信用卡号直接提交给支付服务商,不经过业务服务器或日志系统
- 不保存 CCV/CVV,包括数据库、日志、监控、错误报告和客服工单
- 所有支付页面及接口都使用 HTTPS
- 前端使用支付服务商的官方组件,不自行实现卡号输入和 tokenization
- 支付密钥存放在密钥管理系统中,并区分测试与生产环境
- 服务端接口执行登录验证、权限校验和 CSRF 防护
- 所有续费操作使用稳定的订单号和幂等键
- Webhook 验证签名,并对重复事件进行幂等处理
- 保存用户授权和条款版本,以便审计
- 提供查看、更换和删除支付方式的入口
- 提供关闭自动续费的入口,并及时停止后续扣款
- 扣款金额由服务端计算,不信任浏览器提交的金额
- 定期核对账单、支付、退款和争议状态
- 根据实际业务模式完成适用的 PCI DSS 自评或审计
商户系统只需管理用户、订阅、账单、授权记录和支付 token。卡号、CCV、持卡人认证和底层卡片凭据都应交给合规的支付服务商处理。采用这种架构,可以在实现年度自动续费的同时,把敏感数据的暴露范围控制在合理水平。
备注:内容仅供参考。
