支付单、业务单、退款单:一次订场退款里的三表对齐
背景
「快约球」订场链路里,用户支付成功后会同时存在:
| 概念 | 表 / 字段 | 职责 |
|---|---|---|
| 业务订场单 | venue_bookings(pay_status/booking_status) | 场次是否确认、是否取消 |
| 支付单 | payment_orders(status) | 与微信商户单号、金额、用户绑定 |
| 退款流水 | payment_refunds | out_refund_no、金额、微信退款号、来源 |
早期取消订场只改业务表,并返回mock_refunded/wechat_pending占位——资金侧没有真实退款,后台也没有按会员聚合的订单视图。这在演示可以,在对账不行。
目标
- 用户取消与后台退款走同一条
PaymentService.refundPaidOrder。 - 已支付订场退款后:支付单
refunded,业务单取消并释放slot_offer。 - Admin 能按
userId列出该会员全部支付单,并对paid发起退款。 - 微信未就绪且开启 mock 时,明确记
mock,不假装已打到微信。
状态机(支付单)
pending → paid → refunding → refunded ↘ closed / failed(下单失败或关单)说明:
refunding:已向微信发起或本地进入退款流程。- 微信返回
SUCCESS:落payment_refunds.status=success,订单refunded,再执行业务冲正。 - 微信返回
PROCESSING:退款流水记processing,业务上仍可先释放场地(避免档期继续被占),资金到账靠微信侧异步完成;产品文案不要写「秒到账」。
关键实现要点
1. 退款入口统一
Admin: POST /api/v1/admin/orders/{id}/refund User cancel (已支付订场): refundVenueBookingIfPaid(bookingId) ↓ PaymentService.refundPaidOrder(outTradeNo, reason, source) ↓ WeChatPayV3Client.createRefund(...) 或 mock ↓ applyBizAfterRefund → venueDirectBookingService.applyRefundCancel(bookingId)source区分admin/user_cancel,方便以后审计。
2. 三表写入顺序
建议顺序:
- 插入
payment_refunds(pending) - 支付单
paid → refunding - 调微信(或 mock)
- 更新退款流水 + 支付单
refunded - 业务冲正(订场取消 + 释放时段)
微信调用失败时:退款流水failed,支付单回退到paid,避免卡在refunding无法重试。
3. 按会员查单
Admin 列表增加userId条件,并对users.username / nickname做关键词检索。
业务类型包含:player_membership、team_vip、venue_booking。
这比单独再做一张「会员订单宽表」更便宜:支付域本来就有user_id。
4. 会员 / VIP 退款的产品选择
订场退款会冲正业务;会员与 VIP只退支付单,不自动撤销权益。
原因:权益回收涉及到期日、叠加开通、人工赠送,自动扣回容易误伤。钱和权分离,交给运营。
接口与表(便于对照)
- Flyway:
V60__payment_refunds.sql(另有启动兜底PaymentRefundsTableMigration) - 微信:
POST /v3/refund/domestic/refunds - 支付单扩展状态:
refunding/refunded
小结
| 坑 | 建议 |
|---|---|
| 只改业务表、资金占位 | 用户取消与后台退款必须共用履约 |
| 退款失败卡在 refunding | 失败回退paid,允许重试 |
| PROCESSING 当失败 | 可先释放库存,文案写清「处理中」 |
| 会员退款自动降级 | 先退款,权益回收单独策略 |
远程库 + 微信退款的系统里,状态对齐比多做一个按钮重要。先让三张表能互相解释,再谈分账与对账报表。