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
@@ -249,6 +249,55 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
return changed, err
}
// RefundOrder 人工退款(PAYMENT_DESIGN §5admin 发起 → 订单置 refunded + 记 adjust 负分录 + 回退余额)。
// 只退 paid 单。幂等照抄 MarkOrderPaid 范式:**订单 CAS(paid→refunded) 为主闸**——RowsAffected==0
// 表示已退过(或非 paid),直接幂等成功,不重复冲销。credit_ledger 的 (kind='adjust', ref=订单号)
// 部分唯一索引兜底(与 grant 的双闸对称)。
// 注:若积分已被消费,回退后物化余额可能为负——这是人工退款的预期(钱退了、积分早花了),
// 账本仍自洽(余额 = SUM(ledger)),后续消费被积分硬拦截挡住直到再充值。
// 返回 changed=false 表示这单本就无需退(已退/未支付/不存在),幂等。
func (p *Postgres) RefundOrder(ctx context.Context, orderID, operatorUserID, memo string) (bool, error) {
if p.db == nil {
return false, errStoreDisabled
}
if strings.TrimSpace(orderID) == "" {
return false, errors.New("订单号必填")
}
if strings.TrimSpace(memo) == "" {
memo = "人工退款"
}
changed := false
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
res := tx.Model(&PaymentOrder{}).
Where("id = ? AND status = ?", orderID, OrderPaid).
Update("status", OrderRefunded)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil // 已退 / 非 paid / 不存在 —— 幂等,不冲销
}
var o PaymentOrder
if err := tx.First(&o, "id = ?", orderID).Error; err != nil {
return err
}
// 负分录:kind=adjust、ref=订单号(不撞 grant 的 kind=grant 同 refadjust 部分唯一索引兜住重复退)。
if err := tx.Create(&CreditLedger{
TenantID: o.TenantID, Kind: LedgerAdjust, CreditsMicro: -o.CreditsMicro, Ref: o.ID,
Memo: "退款 " + o.Channel + "" + memo + "(操作人 " + operatorUserID + "",
}).Error; err != nil {
return err
}
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", o.CreditsMicro)).Error; err != nil {
return err
}
changed = true
return nil
})
return changed, err
}
// PendingWechatOrders 捞出所有 pending 的微信订单(掉单补偿定时器扫描用)。
// 只取微信单:兑换码单核销即 paid,永不 pending,不需要查单。按创建时间升序,先补老单。
func (p *Postgres) PendingWechatOrders(ctx context.Context, limit int) ([]PaymentOrder, error) {
@@ -158,6 +158,82 @@ func TestSaveUsageEvent_IdempotentByTask(t *testing.T) {
assertBalanceInvariant(t, p, "t1")
}
// RefundOrderpaid 单退款——置 refunded + adjust 负分录 + 回退余额,且 CAS 幂等(重复退不重复冲销)。
func TestRefundOrder_ReversesAndIdempotent(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
seedTenant(t, p, "t1")
// 先充一笔微信单入账。
o := &PaymentOrder{TenantID: "t1", UserID: "u1", AmountFen: 990, CreditsMicro: 100_000_000, Channel: ChannelWechat, Status: OrderPending}
if err := p.CreateOrder(ctx, o); err != nil {
t.Fatalf("建单失败: %v", err)
}
if _, err := p.MarkOrderPaid(ctx, o.ID, "wx-txn"); err != nil {
t.Fatalf("入账失败: %v", err)
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 100_000_000 {
t.Fatalf("入账后余额应 100e6,得 %d", bal)
}
// 退款 → changed=true,余额回退到 0,订单置 refunded。
changed, err := p.RefundOrder(ctx, o.ID, "admin1", "客户申请")
if err != nil || !changed {
t.Fatalf("首次退款应 changed=true, err=%v", err)
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 0 {
t.Fatalf("退款后余额应回 0,得 %d", bal)
}
got, _ := p.GetOrder(WithoutTenant(ctx), o.ID)
if got.Status != OrderRefunded {
t.Fatalf("订单应 refunded,得 %s", got.Status)
}
// 重复退款 → changed=false(CAS 拦),余额纹丝不动,不产生第二条负分录。
changed2, err := p.RefundOrder(ctx, o.ID, "admin1", "重复点")
if err != nil {
t.Fatalf("重复退款不应报错: %v", err)
}
if changed2 {
t.Fatal("重复退款应 changed=false(幂等)")
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 0 {
t.Fatalf("重复退款后余额不应变,得 %d", bal)
}
assertBalanceInvariant(t, p, "t1")
}
// RefundOrder:只退 paid 单——pending/未存在的单退款是幂等 no-opchanged=false),不误伤。
func TestRefundOrder_OnlyPaid(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
seedTenant(t, p, "t1")
// pending 单不可退。
o := &PaymentOrder{TenantID: "t1", UserID: "u1", AmountFen: 100, CreditsMicro: 1_000_000, Channel: ChannelWechat, Status: OrderPending}
p.CreateOrder(ctx, o)
changed, err := p.RefundOrder(ctx, o.ID, "admin1", "")
if err != nil {
t.Fatalf("退 pending 单不应报错: %v", err)
}
if changed {
t.Fatal("pending 单不该被退(changed=false")
}
// 余额未动、订单仍 pending。
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 0 {
t.Fatalf("退 pending 后余额应仍 0,得 %d", bal)
}
got, _ := p.GetOrder(WithoutTenant(ctx), o.ID)
if got.Status != OrderPending {
t.Fatalf("pending 单状态不该变,得 %s", got.Status)
}
// 不存在的单 → 幂等 no-op。
if changed, err := p.RefundOrder(ctx, "no-such-order", "admin1", ""); err != nil || changed {
t.Fatalf("退不存在的单应 no-opchanged=false, err=nil),得 changed=%v err=%v", changed, err)
}
}
// ReconcileOrderspaid 订单缺对应 grant 分录 → 抓出 order_without_ledger(钱到了积分没给,最严重)。
func TestReconcileOrders_DetectsMissingLedger(t *testing.T) {
p := newTestStore(t)
+5
View File
@@ -75,6 +75,11 @@ func OpenPostgres(dsn string) *Postgres {
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_grant_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'grant' AND ref <> ''`).Error; err != nil {
log.Printf("[store] 账本 grant/ref 唯一索引创建失败(重复入账兜底闸缺位): %v", err)
}
// 退款幂等兜底闸:adjust 分录带 ref(=订单号) 唯一——防重复退款冲销。
// 部分索引:admin 手工校正(GrantCredits 负数)ref 为空,不受约束;与 grant 双闸对称。
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refund_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'adjust' AND ref <> ''`).Error; err != nil {
log.Printf("[store] 账本 adjust/ref 唯一索引创建失败(重复退款兜底闸缺位): %v", err)
}
registerTenantScope(db) // 多租户:受租户模型的查询/创建自动按上下文注入 tenant_id(统一强制隔离)
log.Println("[store] postgres connected & migrated (雪花 id + 软删 规约)")
return &Postgres{db: db}
@@ -36,6 +36,9 @@ func newTestStore(t *testing.T) *Postgres {
if err := db.Exec(`CREATE UNIQUE INDEX idx_ledger_grant_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'grant' AND ref <> ''`).Error; err != nil {
t.Fatalf("建幂等索引失败: %v", err)
}
if err := db.Exec(`CREATE UNIQUE INDEX idx_ledger_refund_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'adjust' AND ref <> ''`).Error; err != nil {
t.Fatalf("建退款幂等索引失败: %v", err)
}
registerTenantScope(db)
return &Postgres{db: db}
}