feat(v2): gateway.Refund 编排(部分/多次累计 + 超退守卫 + crypto 人工向闭环)+ RefundingProvider 加 refundID 幂等键
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
package gateway_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user