package gateway_test import ( "context" "errors" "fmt" "testing" "github.com/wangjia/pay/internal/gateway" "github.com/wangjia/pay/internal/model" "github.com/wangjia/pay/internal/provider" ) // 把 fake 订单推到 paid(复用 P2/P3 的 SyncPendingAttempts + SetQueryResult 路径)。 func createAndPay(t *testing.T, g *gateway.Gateway, fp interface { SetQueryResult(string, provider.PaidEvent) }, orders interface { ListAttemptsByStatus(model.AttemptStatus, int) ([]model.Attempt, error) }) string { t.Helper() res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{ SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1", }) if err != nil { t.Fatalf("create: %v", err) } atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) fp.SetQueryResult(atts[0].ProviderRef, provider.PaidEvent{ ProviderRef: atts[0].ProviderRef, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT", }) if _, err := g.SyncPendingAttempts(context.Background(), 10); err != nil { t.Fatalf("sync: %v", err) } return res.OrderNo } func TestRefundPartialThenFull(t *testing.T) { g, fp, spy, orders := newGateway(t) fp.EnableRefund("fake-refund-ref", provider.PaidSucceeded, nil) // fake 变可退渠道,同步成功 no := createAndPay(t, g, fp, orders) // 部分退 1/3(总价 29990000) r1, err := g.Refund(context.Background(), gateway.RefundInput{ OutTradeNo: no, AmountMinor: 9990000, Reason: "test", BizSystem: "pangolin", }) if err != nil || r1.Status != string(model.RefundSucceeded) { t.Fatalf("refund1 = %+v, %v", r1, err) } if o, _ := orders.GetOrder(no); o.Status != model.OrderPartRefundedV2 { t.Fatalf("after partial: order = %s want partially_refunded", o.Status) } // 退款事件已入队(refund.succeeded,带 refund_id) last := spy.calls[len(spy.calls)-1] if last["event_type"] != "refund.succeeded" || last["refund_id"] != r1.RefundID { t.Fatalf("refund webhook = %+v", last) } // 超退守卫:再退 25000000 > 剩余 20000000 → 拒 if _, err := g.Refund(context.Background(), gateway.RefundInput{ OutTradeNo: no, AmountMinor: 25000000, BizSystem: "pangolin", }); !errors.Is(err, gateway.ErrRefundAmountInvalid) { t.Fatalf("over-refund err = %v want ErrRefundAmountInvalid", err) } // 退剩余 → refunded r2, err := g.Refund(context.Background(), gateway.RefundInput{ OutTradeNo: no, AmountMinor: 20000000, BizSystem: "pangolin", }) if err != nil || r2.Status != string(model.RefundSucceeded) { t.Fatalf("refund2 = %+v, %v", r2, err) } if o, _ := orders.GetOrder(no); o.Status != model.OrderRefundedV2 { t.Fatalf("after full: order = %s want refunded", o.Status) } } func TestRefundNotRefundableWhenPending(t *testing.T) { g, _, _, _ := newGateway(t) res, _ := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake"}) if _, err := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: res.OrderNo, AmountMinor: 1}); !errors.Is(err, gateway.ErrOrderNotRefundable) { t.Fatalf("err = %v want ErrOrderNotRefundable", err) } } func TestRefundManualForCryptoLikeChannel(t *testing.T) { g, fp, spy, orders := newGateway(t) // fp 不 EnableRefund → SupportsRefund=false → 建 manual_pending,不动 order、不发事件 no := createAndPay(t, g, fp, orders) nCalls := len(spy.calls) r, err := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: no, AmountMinor: 9990000, BizSystem: "pangolin"}) if err != nil || r.Status != string(model.RefundManualPending) { t.Fatalf("manual refund = %+v, %v", r, err) } if o, _ := orders.GetOrder(no); o.Status != model.OrderPaidV2 { t.Fatalf("manual pending 不应动 order,得 %s", o.Status) } if len(spy.calls) != nCalls { t.Fatal("manual pending 不应发退款事件") } // 待办可捞 list, _ := g.ListManualPendingRefunds(10) if len(list) != 1 || list[0].RefundID != r.RefundID { t.Fatalf("manual list = %+v", list) } // 运营回填完成 → 成功落地 + 事件 + 态机 done, err := g.CompleteManualRefund(context.Background(), r.RefundID, "tron-tx-hash") if err != nil || done.Status != string(model.RefundSucceeded) { t.Fatalf("complete = %+v, %v", done, err) } if o, _ := orders.GetOrder(no); o.Status != model.OrderPartRefundedV2 { t.Fatalf("after complete: order = %s want partially_refunded", o.Status) } last := spy.calls[len(spy.calls)-1] if last["event_type"] != "refund.succeeded" || last["provider_refund_ref"] != "tron-tx-hash" { t.Fatalf("complete webhook = %+v", last) } } // 渠道明确拒绝(errors.Is(rerr, provider.ErrRefundRejected))→ 现行为:标 failed, // 释放预留额度(RefundSum 守卫按 status<>failed 计,故 failed 后同单能再退全款)。 func TestRefundChannelRejectedMarksFailed(t *testing.T) { g, fp, spy, orders := newGateway(t) rejectErr := fmt.Errorf("channel: 交易已完结: %w", provider.ErrRefundRejected) fp.EnableRefund("", provider.PaidFailed, rejectErr) no := createAndPay(t, g, fp, orders) nCalls := len(spy.calls) res, err := g.Refund(context.Background(), gateway.RefundInput{ OutTradeNo: no, AmountMinor: 9990000, Reason: "test", BizSystem: "pangolin", }) if !errors.Is(err, provider.ErrRefundRejected) { t.Fatalf("err = %v want wrap ErrRefundRejected", err) } if res == nil || res.Status != string(model.RefundFailed) { t.Fatalf("res = %+v want status=failed", res) } got, gerr := g.GetRefund(res.RefundID) if gerr != nil || got.Status != string(model.RefundFailed) { t.Fatalf("GetRefund = %+v, %v want status=failed", got, gerr) } if len(spy.calls) != nCalls+1 || spy.calls[len(spy.calls)-1]["event_type"] != "refund.failed" { t.Fatalf("渠道拒绝应发 refund.failed 事件, calls=%+v", spy.calls) } // 额度已释放(failed 不占 reserved)→ 同单再退全款不应被超退守卫拦下(即便这次 // fp 仍预置拒绝、第二笔也会 failed,关键是不撞 ErrRefundAmountInvalid)。 if _, err := g.Refund(context.Background(), gateway.RefundInput{ OutTradeNo: no, AmountMinor: 29990000, BizSystem: "pangolin", }); errors.Is(err, gateway.ErrRefundAmountInvalid) { t.Fatalf("failed 退款不应继续占额度,再退全款不应被超退守卫拒: %v", err) } } // 歧义渠道错误(超时/网络/5xx 等,不满足 errors.Is(..., ErrRefundRejected))→ 不得标 // failed:留 processing 继续占预留额度,交人工/P6 RefundStuckAlertTask 对账收敛。 func TestRefundAmbiguousChannelErrorStaysProcessing(t *testing.T) { g, fp, spy, orders := newGateway(t) ambiguousErr := errors.New("channel: dial tcp: i/o timeout") fp.EnableRefund("", provider.PaidFailed, ambiguousErr) no := createAndPay(t, g, fp, orders) nCalls := len(spy.calls) res, err := g.Refund(context.Background(), gateway.RefundInput{ OutTradeNo: no, AmountMinor: 9990000, Reason: "test", BizSystem: "pangolin", }) if err == nil || errors.Is(err, provider.ErrRefundRejected) { t.Fatalf("err = %v want plain(non-rejected) error", err) } if res == nil || res.Status != string(model.RefundProcessing) { t.Fatalf("res = %+v want status=processing(留待人工/对账,不得标 failed)", res) } got, gerr := g.GetRefund(res.RefundID) if gerr != nil || got.Status != "processing" { t.Fatalf("GetRefund = %+v, %v want status=processing(不得改状态)", got, gerr) } // 歧义错误不应发 refund.failed 事件(渠道结果未定,不能对业务方宣告失败)。 if len(spy.calls) != nCalls { t.Fatalf("歧义错误不应发退款事件, calls=%+v", spy.calls) } // 预留额度仍被占用(processing 计入 reserved)→ 同单再退到超额应被守卫拒。 if _, err := g.Refund(context.Background(), gateway.RefundInput{ OutTradeNo: no, AmountMinor: 25000000, BizSystem: "pangolin", }); !errors.Is(err, gateway.ErrRefundAmountInvalid) { t.Fatalf("processing 应继续占额度,超额再退应被超退守卫拒, got %v", err) } }