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:
@@ -158,6 +158,82 @@ func TestSaveUsageEvent_IdempotentByTask(t *testing.T) {
|
||||
assertBalanceInvariant(t, p, "t1")
|
||||
}
|
||||
|
||||
// RefundOrder:paid 单退款——置 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-op(changed=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-op(changed=false, err=nil),得 changed=%v err=%v", changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileOrders:paid 订单缺对应 grant 分录 → 抓出 order_without_ledger(钱到了积分没给,最严重)。
|
||||
func TestReconcileOrders_DetectsMissingLedger(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
|
||||
Reference in New Issue
Block a user