微信支付 APIv3 退款卡在受理之后:PROCESSING 转 CLOSED、ABNORMAL、NOT_ENOUGH 怎么分
批量退款任务跑完,两百多笔调用全部返回 200,后台列表清一色「处理中」。到第三天再查,其中三十几笔变成 CLOSED,钱没退出去,用户已经在催了。申请退款接口的成功应答只说明退款单被受理,状态推进和资金到账是后面的事;把这两件事混在一起,问题就会卡在这一段。
前提与不适用
适用范围:APIv3 退款单已经受理成功(应答中有 refund_id、out_refund_no),卡在后续状态推进;直联商户与服务商、电商模式共用同一套状态语义。
不适用:签名、证书、请求体校验直接报错(那时根本没有退款单);合单支付想用 combine_out_trade_no 整体退款——不支持,只能按子单填 sub_orders.transaction_id / out_trade_no 逐笔退。
状态模型:终态只有两个
GET /v3/refund/domestic/refunds/{out_refund_no} 返回的 status 有四个值:
SUCCESS退款成功,终态CLOSED退款关闭,终态PROCESSING退款处理中,非终态ABNORMAL退款异常,非终态
只有 SUCCESS 和 CLOSED 是终态。判断是否结束,看这个字段;零钱支付一般 5 分钟内到账、银行卡 1–3 个工作日,这只是给用户看的预计时间,不能当作最终确认成功的依据。
卡点一:PROCESSING 超 7 天转 CLOSED——换号重提
CLOSED 的成因是文档明确指定的:退款单处于「退款处理中」超过 7 天,且这 7 天里出资账户余额不足以完成退款。资金一直从商户基本账户扣不出来,微信侧就把单子关掉。
处置动作只有一条路:充值,然后生成新的 out_refund_no,重新调 POST /v3/refund/domestic/refunds。
这里最容易出问题的是两个「重试」的区别:
- 申请退款接口调用失败(系统超时 500、频率限制 429、网络中断):必须用原商户退款单号、原参数重试。同一
out_refund_no多次请求只退一笔,换号等于新开一笔,可能重复退款。 - 退款单已经走到 CLOSED 终态:原单号已经作废,必须换新的
out_refund_no。CLOSED 之后再拿旧号查,永远是 CLOSED。
判断依据就是本地有没有拿到过受理应答。没有受理应答 → 原号重试;拿到过且被关闭 → 新号重提。
卡点二:ABNORMAL——先走零钱,零钱也注销才异常
ABNORMAL 的成因是原路退回银行卡时发现用户卡已作废或冻结,银行侧退不回去。这时微信会先尝试转到用户微信零钱,只有零钱账户也已注销,才落到 ABNORMAL。
处置有两条:商户平台交易中心手动处理,或调用发起异常退款接口 POST /v3/ecommerce/refunds/{refund_id}/apply-abnormal-refund(平台商户 / 服务商路径)。提交后资金停留在退款中间账户,不会自动回用户。
type 两个取值:
USER_BANK_CARD:退到用户银行卡,仅支持 15 家直联银行的借记卡——招商、交通、农业、建设、工商、中国、平安、浦发、中信、光大、民生、兴业、广发、邮储、宁波。MERCHANT_BANK_CARD:退至交易商户的银行账户。
bank_account 与 real_name 需要敏感信息加密。该接口请求限制为 150qps。用户卡是信用卡、或不在 15 家名单里,USER_BANK_CARD 这条路走不通。
卡点三:NOT_ENOUGH——账户余额,以及分账订单的资金结构
403 NOT_ENOUGH 是商户账户余额不足。先查基本账户,再看两件容易忽略的事。
分账订单退款时,已分出去的钱不会自动回退分账金额,退款出资用的是子商户基本账户的可用余额。具体规则:
- 未分账:直接退,资金来自不可用余额。
- 部分分账:全额退款要先调「完结分账」解冻剩余冻结资金,解冻后可用余额 > 退款金额;部分退款若金额 ≤ 未分账冻结金额可直接退(出资不可用余额),超过则先完结分账。
- 已完结分账:解冻后可用余额 > 退款金额可直接退。
amount.total 填的是原支付交易的订单总金额,不是本次退款金额;本次退多少填在 amount.refund,单位分、整数。填错会直接表现为校验不过或余额判断异常。
另外两个硬性限制:部分退款最多 50 次,多次部分退款必须换 out_refund_no 并间隔 1 分钟以上再调;退款有效期是支付成功起算 365 天。
状态机判断片段(Go)
type RefundStatus string
const (
StatusProcessing RefundStatus = "PROCESSING"
StatusSuccess RefundStatus = "SUCCESS"
StatusClosed RefundStatus = "CLOSED"
StatusAbnormal RefundStatus = "ABNORMAL"
)
// 幂等键统一用 out_refund_no;查询与通知都按它落库
func handleRefund(ctx context.Context, r Refund) error {
switch RefundStatus(r.Status) {
case StatusSuccess:
// 唯一结单依据
return markRefunded(ctx, r.OutRefundNo, r.RefundID)
case StatusClosed:
// 终态:原单号不再重试;充值后生成新 out_refund_no 重提
return enqueueManual(ctx, "refund_closed_recreate", r.OutRefundNo)
case StatusAbnormal:
// 卡作废/冻结,零钱也注销;走商户平台或异常退款接口
return enqueueManual(ctx, "refund_abnormal", r.OutRefundNo)
case StatusProcessing:
if time.Since(r.CreateTime) > 7*24*time.Hour {
// 已超 7 天仍在处理中,核对基本账户余额,准备人工介入
return enqueueManual(ctx, "refund_processing_overdue", r.OutRefundNo)
}
return scheduleQuery(ctx, r.OutRefundNo, nextInterval(r))
}
return nil
}
查询节奏:受理后间隔 1 分钟查一次,超过 5 分钟仍 PROCESSING 就逐步降低频率(5/10/20/30 分钟)。查询接口同商户号限 300qps。退款接口成功调用限 150QPS、失败报错时限制为 6QPS,报 429 FREQUENCY_LIMITED 表示该笔退款正在受理中,不要更换单号,间隔 1 分钟用原单号重试或直接查单确认。
回调
退款结果通知发到申请退款时传的 notify_url,需 APIv3 密钥解密。接口传了 notify_url,商户平台配置的回调地址就不再生效;接口和商户平台交易中心-退款管理-退款配置至少配一处,两处都没配就收不到回调,只能靠轮询查询。
排查顺序清单
- 调
GET /v3/refund/domestic/refunds/{out_refund_no},以status为准,不看本地缓存和用户到账反馈。 PROCESSING:算create_time到现在的时长,接近 7 天就开始核基本账户余额。CLOSED:充值 → 生成新out_refund_no→ 重提;旧单归档,不再轮询。ABNORMAL:确认用户卡状态与零钱账户,选USER_BANK_CARD(限 15 家直联行借记卡)或MERCHANT_BANK_CARD,字段做敏感信息加密。NOT_ENOUGH:查基本账户可用余额 → 查分账状态(是否已分账、是否需要完结分账)→ 核对amount.total是否误填成本次退款额。- 该收的回调没收到:先看申请时是否传了
notify_url,再看商户平台是否配了回调地址,最后看解密是否报错。 - 接口报错重试:
SYSTEM_ERROR、FREQUENCY_LIMITED、RESOURCE_NOT_EXISTS都先确认本地是否已受理;未受理则用原单号原参数重试,已受理不重复发起。 - 支付成功超过 365 天、部分退款已满 50 次、或两笔部分退款间隔不足 1 分钟,走人工工单而不是脚本重试。
口径说明
文中 7 天关闭、15 家直联行名单、50 次部分退款上限、间隔 1 分钟、365 天可退、150/6/300QPS、1–3 个工作日到账、5/10/20/30 分钟衰减查询,均来自商户文档口径,未在真实商户号上逐项实测;以接入时对应版本的官方文档为准。