PAYATHON 2026

如何在最终金额未知时完成支付扣款?

支付阿杰

结论

可以实现。对于金额尚未确定的订单,不适合做一笔长期 pre-authorization。更合适的流程是:

  1. 客户注册时,由支付服务商收集银行卡信息;
  2. 支付服务商返回无法直接还原卡号的 payment_method_id 或 token;
  3. 商户只保存客户标识和支付凭证标识,不保存卡号、CVV;
  4. 客户下单时,明确同意按照最终重量和计价规则扣款;
  5. 服务完成并确定金额后,使用已保存的支付凭证发起一次 off-session 扣款。

这类流程通常称为 card-on-file、credential-on-file 或 merchant-initiated transaction。使用前需要确认支付服务商支持”保存支付方式并在客户不在线时扣款”,同时这种处理方式符合商户所在地、卡组织和业务类别的规则。

为什么不适合长期预授权

预授权更适合酒店押金、租车押金等金额大致可估算、结算周期较短的业务,通常有以下限制:

  • 授权存在有效期,无法无限期保留;
  • 最终捕获金额通常不能任意超过授权金额;
  • 并非所有支付服务商、地区和商户类别都支持增量授权;
  • 授权会暂时占用客户的信用额度;
  • 预授权金额过高容易引起客户疑问,金额过低又可能需要补收。

如果洗衣服务能在短时间内完成,而且金额波动不大,也可以先对预计金额进行预授权,完成后再按实际金额捕获。但如果实际费用可能明显高于预计金额,保存支付方式并在金额确定后扣款会更直接。

推荐的业务流程

1. 注册支付方式

注册页面可以收集姓名、地址等业务资料,但银行卡输入框应使用支付服务商提供的托管组件、Hosted Fields 或 Checkout 页面。

银行卡信息由浏览器直接提交给支付服务商:

客户浏览器
    ├── 姓名、地址 ───────────────> 商户服务器
    └── 卡号、有效期、CVV ───────> 支付服务商
                                      │
                                      └── 返回 payment_method_id

商户数据库只保存类似下面的数据:

{
  "customer_id": "cus_123",
  "payment_method_id": "pm_456",
  "card_brand": "visa",
  "card_last4": "4242",
  "billing_consent_at": "2026-09-01T10:30:00Z"
}

payment_method_id 是支付服务商内部使用的引用,不是加密后的卡号。它一般只能在对应的商户账户和支付服务商体系内使用。

不要保存以下内容:

  • 完整卡号;
  • CVV/CVC;
  • 磁道数据;
  • 支付服务商返回的敏感客户端密钥;
  • 未经保护的支付接口密钥。

2. 建立客户授权

客户下单前需要清楚了解扣款依据。页面可以列明:

  • 每磅单价;
  • 最低消费;
  • 配送费、服务费和税费;
  • 可能产生的附加项目;
  • 称重和计费方式;
  • 预计费用或费用区间;
  • 扣款时间;
  • 取消、退款和争议处理规则。

授权文案应写清具体规则,不能只写”同意以后扣款”。例如:

我授权商户在服务完成后,按照订单所示单价、实际重量及已披露费用,从我保存的支付方式中扣除本订单最终金额。

是否还需要电子签名、复选框或特定格式,应根据经营地区、收单机构和卡组织的要求确认。

3. 创建取件订单

此时可以创建业务订单,不一定要立即产生支付交易:

{
  "order_id": "ord_1001",
  "customer_id": "cus_123",
  "status": "pickup_scheduled",
  "pickup_at": "2026-09-02T18:00:00+08:00",
  "unit_price": 18.00,
  "currency": "CNY"
}

订单中应保存下单时适用的价格快照。不要只引用一张可能随时修改的价格表,否则以后很难解释具体扣款金额是如何得出的。

如果业务风险较高,可以在这个阶段进行小额验证,或者对预计金额做预授权。但不能认定”小额扣款后立即退款”在所有地区都符合规则。应优先使用支付服务商提供的银行卡验证或支付方式设置接口。

4. 服务完成后计算金额

最终金额应由服务端计算,不能直接采用浏览器传入的金额。例如:

subtotal = actual_weight × unit_price
total = subtotal + delivery_fee + service_fee + tax - discount

重量保留的小数位、舍入方式和最低计费重量也要提前确定。例如:

计费重量 = ceil(actual_weight × 10) / 10

这项公式表示计费重量向上取整到 0.1 磅。如果采用该规则,必须在客户下单前披露。

进入结算前还要执行幂等检查,确保同一订单只能进入一次扣款流程。

5. 发起离线扣款

下面是与供应商无关的 Node.js 风格示例。实际使用时,应根据所选支付服务商的官方 SDK 替换接口名称和字段:

async function chargeCompletedOrder(orderId) {
  const order = await db.orders.findById(orderId);

  if (!order) {
    throw new Error("Order not found");
  }

  if (order.status !== "ready_for_payment") {
    throw new Error("Order is not ready for payment");
  }

  if (order.payment_transaction_id) {
    return {
      status: "already_processed",
      transactionId: order.payment_transaction_id
    };
  }

  const amountMinor = calculateAmountInMinorUnits(order);

  const payment = await paymentProvider.createPayment({
    customer: order.provider_customer_id,
    payment_method: order.payment_method_id,
    amount: amountMinor,
    currency: order.currency,
    off_session: true,
    confirm: true,
    metadata: {
      order_id: order.id
    },
    idempotency_key: `charge-order-${order.id}`
  });

  await db.transaction(async (tx) => {
    await tx.payments.insert({
      order_id: order.id,
      provider_transaction_id: payment.id,
      amount_minor: amountMinor,
      status: payment.status
    });

    await tx.orders.update(order.id, {
      status: payment.status === "succeeded"
        ? "paid"
        : "payment_pending",
      payment_transaction_id: payment.id
    });
  });

  return payment;
}

支付金额应使用最小货币单位的整数表示,以免产生浮点数误差:

function calculateAmountInMinorUnits(order) {
  const billableWeight =
    Math.ceil(order.actualWeightPounds * 10) / 10;

  const subtotal =
    billableWeight * order.unitPrice;

  const total =
    subtotal +
    order.deliveryFee +
    order.serviceFee +
    order.tax -
    order.discount;

  return Math.round(total * 100);
}

这里的 100 仅适用于保留两位小数的货币。不同货币对最小单位的规定可能不同,应通过货币配置处理,不能统一写死。

必须处理扣款失败

保存支付方式并不意味着后续扣款一定会成功。常见的失败原因有:

  • 余额或信用额度不足;
  • 卡片过期、挂失或已被更换;
  • 发卡行拒绝离线交易;
  • 交易被风控系统拦截;
  • 客户所在地区要求再次完成身份验证;
  • 保存支付方式时没有正确取得后续扣款授权。

支付服务商有时会返回”需要客户操作”的状态。遇到这种情况不能连续重试,应向客户发送安全支付链接,请客户重新验证或更换支付方式:

switch (payment.status) {
  case "succeeded":
    await sendReceipt(order);
    break;

  case "requires_customer_action":
    await sendAuthenticationLink(order);
    break;

  case "declined":
    await requestAnotherPaymentMethod(order);
    break;

  default:
    await queueForPaymentReview(order);
}

重试策略应规定次数和间隔,同时区分临时失败与永久失败。对已经明确永久拒付的交易反复发起请求没有意义,还可能触发支付服务商的风控。

Webhook 才是最终状态依据

支付接口的即时响应可能因网络超时而丢失,但交易本身或许已经成功。因此,不能只根据前端页面或单次 API 响应判断订单是否已付款。

系统应接收并验证支付服务商的 webhook:

app.post("/webhooks/payments", rawBodyParser, 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");
  }

  if (await db.webhookEvents.exists(event.id)) {
    return res.sendStatus(200);
  }

  await db.transaction(async (tx) => {
    await tx.webhookEvents.insert({
      event_id: event.id,
      event_type: event.type
    });

    if (event.type === "payment.succeeded") {
      await tx.orders.markPaidByTransactionId(
        event.data.transaction_id
      );
    }

    if (event.type === "payment.failed") {
      await tx.orders.markPaymentFailedByTransactionId(
        event.data.transaction_id
      );
    }
  });

  return res.sendStatus(200);
});

实现时应使用支付服务商指定的原始请求体和签名验证方法。各家服务商使用的 webhook 请求头、事件结构和重试机制并不相同。

预授权方案仍可作为补充

如果从取件到送回只需要一两天,而且能够合理估算最高费用,可以采用以下流程:

  1. 下单或取件时,预授权预计金额;
  2. 称重后确定实际金额;
  3. 在授权有效期内捕获实际金额;
  4. 如果实际金额超出授权范围,按规则执行增量授权或另行扣款。

采用该方案前,必须向支付服务商确认:

  • 授权有效期;
  • 是否支持延迟捕获;
  • 捕获金额能否低于授权金额;
  • 是否支持增量授权;
  • 最终金额可以偏离授权金额多少;
  • 这项能力是否向当前商户类别开放。

不要自行认定可以”先授权 1 元,之后捕获任意金额”。银行卡授权通常不能这样使用。

安全与合规注意事项

即使银行卡信息不经过商户自己的服务器,商户通常仍要承担一定的 PCI DSS 合规责任,只是合规范围会小很多。商户应完成支付服务商要求的合规流程,并做好以下事项:

  • 全站使用 HTTPS;
  • API 密钥只保存在服务端;
  • 完全分离测试环境和生产环境;
  • 控制后台称重、改价和扣款操作的权限;
  • 记录计费明细、操作人和操作时间;
  • 使用幂等键防止重复扣款;
  • 验证 webhook 签名并对事件去重;
  • 向客户发送明细收据;
  • 允许客户更新或删除已保存的支付方式;
  • 明确支付方式的保存期限、隐私政策和退款规则;
  • 不在日志、邮件或客服系统中记录完整银行卡资料。

对于高金额订单或重量异常的订单,可以在扣款前发送费用确认,也可以设置客户授权上限。如果预计金额超出客户已确认的区间,应先请客户再次确认,以降低拒付风险。

选择支付服务商时,不应询问”能否无限期预授权未知金额”,而应确认它是否支持以下功能:

  • 托管式银行卡采集;
  • 保存支付方式;
  • off-session 或 merchant-initiated 扣款;
  • 必要时要求客户完成身份验证;
  • webhook 通知;
  • 幂等请求;
  • 退款和争议处理。

支付服务商具备这些功能后,商户便可以在不接触客户完整银行卡信息的情况下,等洗衣完成、最终金额确定后再一次性扣款。

备注:内容仅供参考。