打车业务中支付宝商户代扣签约关闭后的扣款方案
结论
通常,用户可以在支付宝 App 中主动关闭商户代扣签约。签约失效后,商户无法再使用原支付许可发起代扣。即使行程已经开始,后续调用同步或异步支付代扣接口也可能失败。
支付宝一般不提供绕过用户解约并继续强制代扣的接口。打车平台需要自行设计兜底流程,包括确认签约状态、正常发起扣款、扣款失败后将订单转为待支付,并引导用户重新签约或主动付款。
用户能否在乘车途中解约,以及关闭签约后何时生效,取决于商户接入的代扣产品、签约协议和支付宝开放平台的当前文档。
为什么会出现这种情况
商户代扣是一种支付授权,并不保证未来订单一定能够完成付款。用户关闭签约后,原授权通常不能再用于发起新的扣款。
需要分清以下三个状态:
- 行程订单已经创建,不等于支付授权仍然有效。
- 支付许可已经创建,不等于后续扣款一定成功。
- 扣款请求已经受理,不等于最终支付成功。异步代扣仍需等待通知或主动查询结果。
即使用户没有关闭签约,代扣也可能因余额不足、支付工具不可用、风控限制或系统异常而失败。因此,业务上不能把“签约成功”当成“订单一定能够收款”。
推荐处理流程
1. 保存签约和支付许可信息
签约成功后,应保存支付宝返回的协议号、签约状态、用户标识和支付许可标识。具体字段名称以实际接入产品的接口文档为准。
不要只在本地保存一个布尔值,例如 signed=true。用户可能直接在支付宝侧关闭签约,导致本地状态过期。
2. 行程开始前检查签约状态
如果接入的产品提供签约查询接口,可以在派单或行程开始前查询一次。发现签约已经失效时,应立即提示用户重新签约或改用其他支付方式。
这项检查只能降低风险,无法保证行程结束时授权依然有效,因为用户可能在检查后关闭签约。
3. 行程结束后正常发起代扣
平台应使用唯一的商户订单号发起扣款,并保存请求流水。即使同步接口返回成功,也要根据产品规则判断该状态是否代表最终支付成功。
异步代扣接口通常只表示请求已被受理,最终结果要以支付宝异步通知或交易查询结果为准。
4. 对失败原因进行分类
扣款失败后,不要无条件重复调用:
- 如果明确表示签约或支付许可已经失效,应停止使用原授权重试。
- 如果明确表示支付失败,应将订单转为待支付。
- 如果返回处理中或结果未知,应先调用交易查询接口确认结果。
- 如果发生网络超时,应使用原商户订单号查询或进行幂等重试,避免重复扣款。
- 如果遇到系统繁忙等可恢复错误,应按照接口文档规定的策略退避重试。
不同代扣产品的错误码可能不同。判断时应以接口实际返回的 code、sub_code 及对应文档为准,不能只看错误文案。
5. 提供主动支付兜底
确认无法完成代扣后,可以向用户展示未支付的行程订单,并提供以下一种或多种付款方式:
- 使用支付宝普通支付完成付款;
- 重新签约后再次授权扣款;
- 使用平台支持的其他支付方式;
- 要求用户在下次叫车前结清欠款。
重新签约后产生的新协议或支付许可,不应直接视为用户同意扣除历史订单费用。能否用于补扣历史费用,需要根据签约页面展示的授权范围、产品规则和合规要求确定。
业务处理示例
下面的代码仅用于展示状态机和幂等处理方式,不对应支付宝 SDK 的某个特定版本。实际接口名称、请求类和字段以商户所接入签约产品的官方文档为准。
public DeductResult settleRide(RideOrder order) {
// 同一个行程始终使用固定的商户订单号
String outTradeNo = order.getPaymentOrderNo();
// 防止重复扣款
PaymentRecord existing = paymentRepository.findByOutTradeNo(outTradeNo);
if (existing != null && existing.isPaid()) {
return DeductResult.success(existing.getTradeNo());
}
Agreement agreement = agreementRepository.findByUserId(order.getUserId());
if (agreement == null || !agreement.isLocallyAvailable()) {
markPendingPayment(order, "NO_AVAILABLE_AGREEMENT");
return DeductResult.requireUserPayment();
}
try {
AlipayDeductResponse response = alipayClient.deduct(
outTradeNo,
order.getPayAmount(),
agreement.getAgreementNo()
);
if (response.isSuccess()) {
markPaid(order, response.getTradeNo());
return DeductResult.success(response.getTradeNo());
}
if (isAgreementInvalid(response.getCode(), response.getSubCode())) {
agreementRepository.markInvalid(agreement.getAgreementNo());
markPendingPayment(order, "AGREEMENT_INVALID");
return DeductResult.requireUserPayment();
}
if (isResultUnknown(response.getCode(), response.getSubCode())) {
return queryAndResolve(outTradeNo, order);
}
markPendingPayment(order, response.getSubCode());
return DeductResult.requireUserPayment();
} catch (TimeoutException exception) {
// 超时不能直接换新订单号再次扣款,应先查询原订单
return queryAndResolve(outTradeNo, order);
}
}
处理异步通知时,还需要完成验签、幂等和金额校验:
public String handleAlipayNotify(Map<String, String> params) {
if (!verifyAlipaySignature(params)) {
return "failure";
}
String outTradeNo = params.get("out_trade_no");
String tradeNo = params.get("trade_no");
String tradeStatus = params.get("trade_status");
String totalAmount = params.get("total_amount");
PaymentRecord payment = paymentRepository.findByOutTradeNo(outTradeNo);
if (payment == null) {
return "failure";
}
if (!payment.getExpectedAmount().toPlainString().equals(totalAmount)) {
return "failure";
}
if (isSuccessfulTradeStatus(tradeStatus)) {
// 更新操作必须具备幂等性
paymentRepository.markPaidIfUnpaid(outTradeNo, tradeNo);
}
return "success";
}
isAgreementInvalid、isResultUnknown 和 isSuccessfulTradeStatus 必须根据实际接入产品的状态码和交易状态实现,不能直接套用其他支付宝产品的错误码。
注意事项
- 不要根据缓存中的签约状态认定扣款一定能够成功。
- 代扣失败后不要持续重试,否则可能触发风控或造成重复扣款。
- 遇到网络超时或同步响应异常时,应先查询原订单,不要直接生成新的商户订单号。
- 收到异步通知后必须验签,并校验商户号、订单号、金额和交易状态。
- 订单、扣款请求和异步通知都必须进行幂等处理。
- 如果支付许可对金额、次数、时间或业务场景有限制,扣款必须严格遵守许可范围。
- 用户关闭签约后,应及时停止展示“自动扣款已开启”,并提供清晰的欠款支付入口。
- 如果业务需要降低逃单风险,可以在行程开始前采用信用评估、保证金、预授权或其他合规措施。具体是否支持,取决于商户实际开通的支付宝产品,不能将普通商户代扣当作资金冻结能力。
备注:内容仅供参考。
