PAYATHON 2026

如何实现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、持卡人认证和底层卡片凭据都应交给合规的支付服务商处理。采用这种架构,可以在实现年度自动续费的同时,把敏感数据的暴露范围控制在合理水平。

备注:内容仅供参考。