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:
@@ -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 在微信商户后台线下操作
|
||||
// —— 本期不接自动退款 API(PAYMENT_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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user