194 lines
7.2 KiB
Go
194 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|