退款不是把钱退回去就完了,说说完整的资金链路
谁会卡在这
用户申请退款,你调了退款接口,微信返回成功。你以为这事完了。
月底对账,商户平台的流水和你系统里的数字对不上。差的那几笔,全是退款。
踩错的代价
退款比支付复杂,而大部分人是按支付的复杂度去设计它的。
支付是一个动作:用户付钱,你收到回调,改状态,完事。
退款是一条链路,而且中间可以被人打断:用户申请、商家审核、可能拒绝、可能要求退货、货要寄回来、商家要收货、然后才退钱。每一步都可能停住,也可能回退。
按支付的模型去做退款,会踩到三样:
状态不够用。 你以为退款只有「退了/没退」,实际至少七种。
钱和货不同步。 退款成功了但货没收到,或者收到货了钱还没退。这两件事在两个系统里,各走各的。
对账对不上。 你系统里记了一笔退款,平台侧记了另一个金额——因为运费退不退、部分退还是全退、优惠券怎么算,这些你没记清楚。
先看「退款」在库里占几张表
支付这件事,在库里基本是一张支付单加一张回调流水。
退款不是。数一下带 refund 的表,5 张:
| 表 | 装什么 |
|---|---|
| 售后单 | 一次售后申请的本体 |
| 售后单明细 | 这次退的是哪几件、各退多少 |
| 售后单状态流水 | 每次状态变更留一条 |
| 购物金退款单 | 用购物金付的那部分,单独一张 |
| 分账退款单 | 走分账的订单,回退也要单独记 |
后两张是这张表最值得看的地方。 它们的存在说明一件事:用户付钱的方式有几种,退款单就可能有几张表——因为退回去的路径跟付进来的路径必须一一对应。
引用到售后单的类,全库 73 个。
再看常量文件,146 行里有三组值得单独说:
售后类型 2 种 仅退款 / 退货退款
退回的货 3 种 不处理 / 良品 / 次品
操作日志 8 种中间那行是第一版设计里绝对想不到的:退回来的货,要标品相。
良品能回架继续卖,次品不能。这个字段决定的是这次退款要不要把库存加回去——判断错了,货架上就出现一件卖不出去的东西,或者少了一件本该能卖的。
8 种操作日志里,有两种也是后来才加的:一个叫「强制退款」(不走商家同意,平台直接退),一个叫「拒收货」(商家收到货但拒绝签收)。
这两个动作在「用户申请 → 商家同意 → 退钱」那张理想流程图上,一个都画不出来。它们是真实世界里退款流程被打断之后,不得不补的口子。
退款有七个状态,不是两个
真实项目里,售后单的状态是这样的:
| 值 | 状态 | 谁触发 |
|---|---|---|
| 0 | 待审核 | 用户提交申请后 |
| 1 | 商家拒绝 | 商家 |
| 2 | 退款中 | 商家同意,钱在路上 |
| 3 | 已退款 | 支付平台回调确认 |
| 4 | 用户退货 | 用户填了物流单号 |
| 5 | 商家待收货 | 货在路上 |
| 6 | 已撤销 | 用户自己取消申请 |
看这张表能看出两件事:
第一,退款是一条独立的状态机,跟主订单状态并行跑。一个订单可以同时是「已收货」和「退款中」——这是完全合法的,用户收到货之后申请退款嘛。
所以退款状态不能塞进订单状态里。 它们是两个维度,不是一条线上的两段。
第二,有分叉。 「用户退货 → 商家待收货 → 已退款」是一条路,「商家同意 → 直接退款」是另一条路。前者要等物流,后者不用。
只退钱不退货(比如虚拟商品、部分退款)和退货退款,是两条不同的路径,代码里得分开。
一张售后单上有 8 个金额字段
上面是表的数量,这一节是单张表的内部。
售后单本体 47 个字段。里头跟钱直接相关的有 8 个,再加 2 个积分字段:
| 字段 | 退给谁 / 从谁那儿扣 |
|---|---|
| 退款金额 | 用户 |
| 退运费金额 | 用户,单独一列 |
| 商家退款金额 | 从商户账户出 |
| 平台退款金额 | 从平台账户出 |
| 退还平台优惠券补贴 | 退回平台 |
| 退一级返佣 | 从推广人那儿扣回来 |
| 退二级返佣 | 同上,第二层 |
| 退款积分抵扣金额 | 积分抵的那部分折成钱 |
| 退还使用积分 | 加回用户积分账户 |
| 扣除赠送积分 | 从用户账户扣走 |
「把钱退回去」这句话,在这张表上是 10 个数。
三个方向值得注意:
一、退款金额被拆成了商家和平台两份。 一笔退款,钱从两个口袋出——因为当初这单的收入也是分给两边的。合成一个「退款金额」,退完就分不清该找谁要。
二、有两个字段是往回扣的,不是往外退的。 退返佣、扣赠送积分——用户退货,之前发出去的奖励要收回来。
而这两笔往往已经被花掉了:佣金可能已经提现,赠送积分可能已经抵扣了下一单。所以这两个字段真正对应的是一个负数余额问题,不是一次转账。
三、运费单独一列。 因为「退不退运费」是一个独立的业务决策,跟退多少货款无关。合并进退款金额,这个决策就没地方记了。
这 10 个数里少记一个,月底对账就差一笔。 而且差的那笔通常不是主金额——主金额谁都记得——是运费、是补贴、是那两笔往回扣的。
完整的资金链路
退款成功只是链路的中间,不是终点。完整的链路是这样:
用户申请
↓
商家审核 ──拒绝──→ 结束
↓ 同意
需要退货?
├ 是 → 用户寄回 → 商家收货
└ 否 ─────────────────┐
↓ │
调用退款接口 ←───────────┘
↓
等待退款回调(这里也要幂等)
↓
├ 改售后状态
├ 写资金流水
└ 回退关联数据
积分 / 优惠券 / 库存 / 分账
↓
进当日对账单
↓
月底汇总核对最容易漏的是右边那一列。 退款不只是把钱退回去,还要把当初因为这笔订单产生的东西回退掉:
- 送出去的积分要扣回来(扣不回来怎么办?余额不够怎么办?)
- 用掉的优惠券要不要退还
- 扣掉的库存要不要加回去
- 已经分出去的账要发起分账回退
这几件事没有一件是自动的。 而且它们各自都可能失败。
三个必须做的事
一、退款回调也要幂等
跟支付回调一模一样的道理:退款回调也会重推。
不幂等的后果是:退款状态被重复处理,关联的积分被扣两次、库存被加两次。 而且因为退款本身的金额是对的,你在对账里看不出来——出问题的是那些「回退动作」。
处理方式跟支付回调一致:已处理就返回成功,别抛异常。
二、资金流水要单独记,而且只增不改
别把资金变动的记录塞在订单表里。
订单表记的是「这个订单现在什么状态」,是一个当前值。资金流水记的是「什么时候发生了什么,金额多少」,是一串事件。
两者的区别在对账的时候会要命:订单表告诉你「这单退款了」,但它不能告诉你「退了多少、什么时候退的、退到哪去了、运费退没退」。
真实项目里这块是分开的:订单流水记录、用户账单、商户日结单、商户月结单——四张表,各管一段。
流水表的规矩只有一条:只追加,不修改。 记错了就再记一条冲正的,不要改原来那条。因为对账靠的是流水的连续性,一旦允许改,你就永远不知道昨天那份对账单是基于哪个版本算的。
三、日结和月结都要有
只做月结对账是不够的。
月底发现差三笔,你要在三十天的数据里找它们。做了日结,你只需要在一天里找。
日结单每天跑一次,跟平台的当日流水核对。差异当天暴露,当天能查。等到月底再对,你连当时发生了什么都想不起来了。
对账对不上,先查这五处
按出现频率排:
一、运费退了没有。 退款金额和退运费金额是两个字段。全额退款要不要退运费,是个业务决策,但代码里必须明确。这一项对不上最常见。
二、部分退款没记清楚。 一个订单退两次,每次退一部分。你系统里记了两条还是一条?金额是累加的还是覆盖的?
三、优惠券和积分抵扣的部分。 用户用 100 元券买了 200 的东西,实付 100。退款退多少?退 100 还是 200?券退不退?这个必须在设计时就定死,写在代码注释里。
四、退款手续费。 有些通道退款是要手续费的,平台侧扣了,你系统里没记。
五、时间边界。 23:59 发起、00:01 成功的退款,算哪天的?你按发起时间记,平台按完成时间记,这笔就跨天了。约定一个口径,两边一致。
常见问题对照表
| 现象 | 真正的原因 | 解法 |
|---|---|---|
| 退款成功了,但积分/库存没回退 | 只处理了退款状态,漏了关联回退 | 把回退动作纳入退款完成的处理单元 |
| 积分被扣了两次 | 退款回调重推,回退动作不幂等 | 退款回调也要幂等,跟支付回调一个道理 |
| 对账差额刚好等于运费 | 退运费金额没算进去,或算重了 | 明确全额退是否含运费,两边口径统一 |
| 部分退款后金额对不上 | 多次退款的记录方式不一致 | 每次退款单独一条流水,不覆盖 |
| 用券订单退款金额有争议 | 抵扣部分怎么退没定义 | 设计时定死并写进注释,别留给运行时判断 |
| 跨天的退款两边归属不同 | 一边按发起时间、一边按完成时间 | 统一口径,通常跟平台侧对齐 |
| 月底对账差几笔,查不出是哪天的 | 只有月结,没有日结 | 加日结,把排查范围从 30 天压到 1 天 |
| 改了历史流水记录后越对越乱 | 流水表允许修改 | 流水只追加不修改,错了记冲正 |
这条链路上的其他几篇
- 《支付回调一定会重复,你的幂等挡不挡得住》
- 《订单状态机是怎么烂掉的,以及怎么不烂》
