如何在最终金额未知时完成支付扣款?
结论
可以实现。对于金额尚未确定的订单,不适合做一笔长期 pre-authorization。更合适的流程是:
- 客户注册时,由支付服务商收集银行卡信息;
- 支付服务商返回无法直接还原卡号的
payment_method_id或 token; - 商户只保存客户标识和支付凭证标识,不保存卡号、CVV;
- 客户下单时,明确同意按照最终重量和计价规则扣款;
- 服务完成并确定金额后,使用已保存的支付凭证发起一次
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 元,之后捕获任意金额”。银行卡授权通常不能这样使用。
安全与合规注意事项
即使银行卡信息不经过商户自己的服务器,商户通常仍要承担一定的 PCI DSS 合规责任,只是合规范围会小很多。商户应完成支付服务商要求的合规流程,并做好以下事项:
- 全站使用 HTTPS;
- API 密钥只保存在服务端;
- 完全分离测试环境和生产环境;
- 控制后台称重、改价和扣款操作的权限;
- 记录计费明细、操作人和操作时间;
- 使用幂等键防止重复扣款;
- 验证 webhook 签名并对事件去重;
- 向客户发送明细收据;
- 允许客户更新或删除已保存的支付方式;
- 明确支付方式的保存期限、隐私政策和退款规则;
- 不在日志、邮件或客服系统中记录完整银行卡资料。
对于高金额订单或重量异常的订单,可以在扣款前发送费用确认,也可以设置客户授权上限。如果预计金额超出客户已确认的区间,应先请客户再次确认,以降低拒付风险。
选择支付服务商时,不应询问”能否无限期预授权未知金额”,而应确认它是否支持以下功能:
- 托管式银行卡采集;
- 保存支付方式;
off-session或 merchant-initiated 扣款;- 必要时要求客户完成身份验证;
- webhook 通知;
- 幂等请求;
- 退款和争议处理。
支付服务商具备这些功能后,商户便可以在不接触客户完整银行卡信息的情况下,等洗衣完成、最终金额确定后再一次性扣款。
备注:内容仅供参考。
