PAYATHON 2026

如何选择简单的 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 或月结账单。信用卡自动充值适合快速启用和中小额消费,大额企业采购则往往更看重发票、付款期限、采购审批和对账信息。

备注:内容仅供参考。