feat(billing): 人工退款入口 —— paid 单冲销积分+置 refunded(幂等)

PAYMENT_DESIGN §5 承诺却只留了 OrderRefunded 常量、无入口。补齐全链路:

- store.RefundOrder:订单 CAS(paid→refunded) 为主闸(重复退 changed=false 幂等),
  同事务记 adjust 负分录(ref=订单号)+ 回退物化余额。照抄 MarkOrderPaid 双闸范式;
  新增 idx_ledger_refund_ref 部分唯一索引(kind='adjust' AND ref<>'')做账本级兜底,
  与 grant 索引对称、不与 admin 手工校正(ref 空)冲突。
- 积分若已消费,回退后余额可为负(人工退款预期,账本仍自洽,后续消费被硬拦截)。
- handler AdminRefundOrder + POST /admin/orders/:id/refund(admin 组已挂 Audit 留痕);
  真渠道钱款原路退回需 admin 另在商户后台操作,本地仅冲销积分与订单态(不接自动退款 API)。
- admin 订单流加「退款」按钮(仅 paid 单可见,二次确认+填原因)。
- 测试:RefundOrder 冲销+幂等、只退 paid 两个不变量测试(sqlite 真 DB,余额=账本之和)。
  gateway build/vet/test 全绿,admin tsc 干净。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-18 13:35:31 +08:00
parent ec9431bfa4
commit 5e5f6e9610
8 changed files with 207 additions and 4 deletions
@@ -298,3 +298,26 @@ func (h *Handler) AdminReconcile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"diffs": rows, "ok": len(rows) == 0})
}
// AdminRefundOrder: POST /api/v1/admin/orders/:id/refund —— 人工退款(PAYMENT_DESIGN §5)。
// 只退 paid 单:订单置 refunded + 记 adjust 负分录 + 回退余额(幂等,可能扣成负余额)。
// 真渠道(微信)退款仅冲销本地积分与订单态,钱的原路退回由 admin 在微信商户后台线下操作
// —— 本期不接自动退款 APIPAYMENT_DESIGN 明确不做),故 memo 里留操作痕迹。
func (h *Handler) AdminRefundOrder(c *gin.Context) {
id := c.Param("id")
var b struct {
Memo string `json:"memo"`
}
_ = c.ShouldBindJSON(&b) // memo 可选
changed, err := h.db.RefundOrder(c.Request.Context(), id, userID(c), b.Memo)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !changed {
// 幂等:本就无需退(已退 / 未支付 / 不存在)。据现状返回可读提示,不当错误。
c.JSON(http.StatusOK, gin.H{"status": "noop", "detail": "订单非已支付状态或已退款,未做冲销"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "refunded", "order_id": id})
}