package payments import ( "context" "errors" "net/http" "testing" "time" ) type refundTestRepo struct { *testRepo refunds map[string]Refund byRefundKey map[string]string records int } func (r *refundTestRepo) ListAdminPaymentOrders(_ context.Context, _ string, _, _ int) ([]AdminOrder, error) { return nil, nil } func (r *refundTestRepo) CreatePaymentRefund(_ context.Context, refund Refund) (Refund, error) { if id := r.byRefundKey[refund.RequestKey]; id != "" { return r.refunds[id], nil } r.refunds[refund.ID] = refund r.byRefundKey[refund.RequestKey] = refund.ID return refund, nil } func (r *refundTestRepo) GetPaymentRefund(_ context.Context, id string) (Refund, error) { refund, ok := r.refunds[id] if !ok { return Refund{}, ErrNotFound } return refund, nil } func (r *refundTestRepo) ListPaymentRefunds(_ context.Context, orderID string) ([]Refund, error) { var out []Refund for _, refund := range r.refunds { if refund.OrderID == orderID { out = append(out, refund) } } return out, nil } func (r *refundTestRepo) PendingPaymentRefunds(_ context.Context, _ int) ([]Refund, error) { var out []Refund for _, refund := range r.refunds { if refund.Status != RefundSuccess && refund.Status != RefundClosed && refund.Status != RefundRejected { out = append(out, refund) } } return out, nil } func (r *refundTestRepo) TouchPaymentRefund(_ context.Context, id string) (Refund, error) { refund := r.refunds[id] refund.UpdatedAt = time.Now().UTC() r.refunds[id] = refund return refund, nil } func (r *refundTestRepo) RejectPaymentRefund(_ context.Context, id, reason string) (Refund, error) { refund := r.refunds[id] refund.Status, refund.FailureReason = RefundRejected, reason r.refunds[id] = refund return refund, nil } func (r *refundTestRepo) RecordPaymentRefund(_ context.Context, txn RefundTransaction) (Refund, error) { refund := r.refunds[txn.ID] if txn.Status == RefundSuccess && refund.Status != RefundSuccess { r.records++ } refund.Status = txn.Status refund.ProviderID = txn.ProviderID r.refunds[txn.ID] = refund return refund, nil } type refundTestProvider struct { *testProvider txn RefundTransaction createErr, queryErr error createIDs []string queries int } func (p *refundTestProvider) CreateRefund(_ context.Context, refund Refund) (RefundTransaction, error) { p.createIDs = append(p.createIDs, refund.ID) if p.createErr != nil { return RefundTransaction{}, p.createErr } txn := p.txn txn.ID, txn.OrderID, txn.MchID, txn.TransactionID = refund.ID, refund.OrderID, refund.MchID, refund.TransactionID txn.AmountFen, txn.TotalFen, txn.Currency = refund.AmountFen, refund.TotalFen, refund.Currency return txn, nil } func (p *refundTestProvider) QueryRefund(_ context.Context, _ string) (RefundTransaction, error) { p.queries++ return p.txn, p.queryErr } func (p *refundTestProvider) ParseRefundNotification(_ context.Context, _ *http.Request) (RefundTransaction, error) { return p.txn, nil } func newRefundService() (*Service, *refundTestRepo, *refundTestProvider, Order) { r := &refundTestRepo{testRepo: &testRepo{orders: map[string]Order{}, byKey: map[string]string{}}, refunds: map[string]Refund{}, byRefundKey: map[string]string{}} p := &refundTestProvider{testProvider: &testProvider{}, txn: RefundTransaction{ProviderID: "wx-refund", Status: RefundProcessing}} s := NewService(r, p, Config{Enabled: true, MchID: "mch", AppID: "app"}) order := Order{ID: "order1", OrganizationID: "org1", MchID: "mch", AppID: "app", TransactionID: "wx-pay", AmountFen: 10000, Currency: "CNY", Status: StatusPaid} r.orders[order.ID] = order return s, r, p, order } func TestRefundAcceptanceDoesNotDebitUntilVerifiedSuccess(t *testing.T) { s, repo, provider, order := newRefundService() refund, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000) if err != nil || refund.Status != RefundProcessing || repo.records != 0 { t.Fatalf("acceptance incorrectly debited: %+v %v %d", refund, err, repo.records) } provider.txn = RefundTransaction{ID: refund.ID, OrderID: order.ID, MchID: "mch", TransactionID: order.TransactionID, ProviderID: "wx-refund", Status: RefundSuccess, AmountFen: 4999, TotalFen: order.AmountFen, Currency: "CNY", SuccessTime: time.Now()} if err := s.RefundNotify(context.Background(), &http.Request{}); !errors.Is(err, ErrVerification) || repo.records != 0 { t.Fatalf("mismatched callback: %v %d", err, repo.records) } provider.txn.AmountFen = 5000 for i := 0; i < 2; i++ { if err := s.RefundNotify(context.Background(), &http.Request{}); err != nil { t.Fatal(err) } } if repo.records != 1 { t.Fatalf("duplicate debit: %d", repo.records) } } func TestRefundTimeoutReusesSameRemoteID(t *testing.T) { s, repo, provider, order := newRefundService() provider.createErr = errors.New("gateway timeout") refund, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000) if err != nil || refund.Status != RefundPending || repo.records != 0 { t.Fatalf("timeout not pending: %+v %v", refund, err) } again, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000) if err != nil || again.ID != refund.ID { t.Fatalf("retry id changed: %+v %v", again, err) } provider.createErr = nil provider.queryErr = ErrRemoteNotFound current := repo.refunds[refund.ID] current.UpdatedAt = time.Now().Add(-time.Minute) repo.refunds[refund.ID] = current if err := s.Reconcile(context.Background()); err != nil { t.Fatal(err) } for _, id := range provider.createIDs { if id != refund.ID { t.Fatalf("new remote refund ID: %q", id) } } if repo.refunds[refund.ID].Status != RefundProcessing || repo.records != 0 { t.Fatalf("unexpected reconciled state: %+v", repo.refunds[refund.ID]) } } func TestRefundValidationAndMerchantRotation(t *testing.T) { s, repo, provider, order := newRefundService() for _, amount := range []int64{0, -1, 10001} { if _, err := s.CreateRefund(context.Background(), order.ID, "admin", "key", "原因", amount); err == nil { t.Fatalf("accepted amount %d", amount) } } if _, err := s.CreateRefund(context.Background(), order.ID, "admin", "key", " ", 100); !errors.Is(err, ErrInvalidAmount) { t.Fatal(err) } if len(provider.createIDs) != 0 { t.Fatal("provider called before validation") } order.MchID = "old-merchant" repo.orders[order.ID] = order if _, err := s.CreateRefund(context.Background(), order.ID, "admin", "key", "原因", 100); !errors.Is(err, ErrConflict) { t.Fatal(err) } } func TestDefinitiveRefundRejectionReleasesPendingAndDoesNotRetry(t *testing.T) { s, repo, provider, order := newRefundService() provider.createErr = &RefundRejectionError{Reason: "商户退款资金不足"} refund, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000) if err != nil || refund.Status != RefundRejected || refund.FailureReason != "商户退款资金不足" || repo.records != 0 { t.Fatalf("definitive rejection: %+v %v", refund, err) } again, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000) if err != nil || again.ID != refund.ID || len(provider.createIDs) != 1 { t.Fatalf("rejected request retried remotely: %+v %v %v", again, err, provider.createIDs) } }