394 lines
17 KiB
Go
394 lines
17 KiB
Go
package biz
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type paymentRepoStub struct {
|
|
create *PaymentResult
|
|
createdReq *PaymentRequest
|
|
refund *PaymentResult
|
|
refundErr error
|
|
refundedReq *PaymentRefundRequest
|
|
callback *PaymentResult
|
|
query *PaymentResult
|
|
queryCalls int
|
|
callbackErr error
|
|
}
|
|
|
|
func (r *paymentRepoStub) ListConfigs(context.Context) ([]*PaymentConfig, error) { return nil, nil }
|
|
func (r *paymentRepoStub) SaveConfig(context.Context, *PaymentConfig) error { return nil }
|
|
func (r *paymentRepoStub) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) {
|
|
copyReq := *req
|
|
r.createdReq = ©Req
|
|
return r.create, nil
|
|
}
|
|
|
|
type paymentOrderRepoStub struct {
|
|
order *PaymentOrder
|
|
}
|
|
|
|
func (r *paymentOrderRepoStub) CreatePaymentOrder(_ context.Context, order *PaymentOrder) (*PaymentOrder, bool, error) {
|
|
if r.order != nil {
|
|
return r.order, false, nil
|
|
}
|
|
copyOrder := *order
|
|
r.order = ©Order
|
|
return r.order, true, nil
|
|
}
|
|
func (r *paymentOrderRepoStub) FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error) {
|
|
if r.order == nil {
|
|
return nil, ErrPaymentOrderNotFound
|
|
}
|
|
return r.order, nil
|
|
}
|
|
func (r *paymentOrderRepoStub) ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, error) {
|
|
if r.order == nil {
|
|
return []*PaymentOrder{}, 0, nil
|
|
}
|
|
return []*PaymentOrder{r.order}, 1, nil
|
|
}
|
|
func (r *paymentOrderRepoStub) RecordPaymentCreate(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
|
|
r.order.PaymentStatus = PaymentStatusPending
|
|
r.order.CreatePayload = append(r.order.CreatePayload[:0], update.CreatePayload...)
|
|
return r.order, nil
|
|
}
|
|
func (r *paymentOrderRepoStub) ApplyPaymentResult(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
|
|
if update.Status == "success" {
|
|
r.order.PaymentStatus = PaymentStatusPaid
|
|
r.order.PaidAmount = r.order.Amount
|
|
r.order.ProviderTradeNo = update.ProviderTradeNo
|
|
}
|
|
return r.order, nil
|
|
}
|
|
func (r *paymentOrderRepoStub) BeginPaymentFulfillment(context.Context, string, string, time.Duration) (*PaymentOrder, string, bool, error) {
|
|
if r.order.FulfillmentStatus == FulfillmentStatusSucceeded {
|
|
return r.order, "", true, nil
|
|
}
|
|
r.order.FulfillmentStatus = FulfillmentStatusProcessing
|
|
return r.order, "token", false, nil
|
|
}
|
|
func (r *paymentOrderRepoStub) CompletePaymentFulfillment(_ context.Context, _, _, _ string, success bool, _ string) (*PaymentOrder, error) {
|
|
if success {
|
|
r.order.FulfillmentStatus = FulfillmentStatusSucceeded
|
|
} else {
|
|
r.order.FulfillmentStatus = FulfillmentStatusFailed
|
|
}
|
|
return r.order, nil
|
|
}
|
|
func (r *paymentOrderRepoStub) BeginPaymentRefund(context.Context, string, string, int64, time.Duration) (*PaymentOrder, string, error) {
|
|
r.order.RefundNo = "refund-1"
|
|
r.order.RefundStatus = RefundStatusProcessing
|
|
return r.order, "refund-token", nil
|
|
}
|
|
func (r *paymentOrderRepoStub) CompletePaymentRefundRequest(context.Context, string, string, string, bool, string) (*PaymentOrder, error) {
|
|
r.order.RefundStatus = RefundStatusPending
|
|
return r.order, nil
|
|
}
|
|
func (r *paymentOrderRepoStub) ConfirmPaymentRefund(context.Context, string, string, string, int64, bool, string) (*PaymentOrder, error) {
|
|
return r.order, nil
|
|
}
|
|
|
|
type paymentSourceStub struct{ intent *PaymentIntent }
|
|
|
|
func (s *paymentSourceStub) Type() string { return s.intent.BusinessType }
|
|
func (s *paymentSourceStub) PreparePayment(context.Context, string, string, string) (*PaymentIntent, error) {
|
|
return s.intent, nil
|
|
}
|
|
func (r *paymentRepoStub) Query(context.Context, string, string) (*PaymentResult, error) {
|
|
r.queryCalls++
|
|
return r.query, nil
|
|
}
|
|
func (r *paymentRepoStub) Refund(_ context.Context, req *PaymentRefundRequest) (*PaymentResult, error) {
|
|
if req != nil {
|
|
copyReq := *req
|
|
r.refundedReq = ©Req
|
|
}
|
|
return r.refund, r.refundErr
|
|
}
|
|
func (r *paymentRepoStub) HandleCallback(context.Context, *PaymentCallback) (*PaymentResult, error) {
|
|
return r.callback, r.callbackErr
|
|
}
|
|
|
|
type paymentFulfillmentStub struct {
|
|
calls int
|
|
failFirst bool
|
|
confirmIDs []string
|
|
}
|
|
|
|
func (h *paymentFulfillmentStub) Type() string { return "game_item" }
|
|
func (h *paymentFulfillmentStub) AuthorizeRefund(context.Context, *PaymentOrder, int64) error {
|
|
return nil
|
|
}
|
|
func (h *paymentFulfillmentStub) Fulfill(_ context.Context, confirmation *PaymentConfirmation) error {
|
|
h.calls++
|
|
h.confirmIDs = append(h.confirmIDs, confirmation.ID)
|
|
if h.failFirst && h.calls == 1 {
|
|
return errors.New("temporary fulfillment failure")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newPaymentUsecaseForTest(repo PaymentRepo) *PaymentUsecase {
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
return NewPaymentUsecase(repo, &paymentOrderRepoStub{order: testBizPaymentOrder()}, logger)
|
|
}
|
|
|
|
func testBizPaymentOrder() *PaymentOrder {
|
|
return &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}
|
|
}
|
|
|
|
func successPaymentResult() *PaymentResult {
|
|
return &PaymentResult{
|
|
Provider: PaymentAlipay,
|
|
Status: "success",
|
|
TradeNo: "order-1",
|
|
ProviderTradeNo: "ali-1",
|
|
Amount: 100,
|
|
Currency: "CNY",
|
|
}
|
|
}
|
|
|
|
func successIntent() *PaymentIntent {
|
|
return &PaymentIntent{
|
|
Provider: PaymentAlipay,
|
|
TradeNo: "order-1",
|
|
BusinessType: "game_item",
|
|
BusinessID: "item-1",
|
|
Amount: 100,
|
|
Currency: "CNY",
|
|
}
|
|
}
|
|
|
|
func TestPaymentCallbackQueriesBeforeFulfillment(t *testing.T) {
|
|
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
|
uc := newPaymentUsecaseForTest(repo)
|
|
_, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
|
|
if err == nil {
|
|
t.Fatal("callback succeeded without fulfillment handler")
|
|
}
|
|
if repo.queryCalls != 1 {
|
|
t.Fatalf("query calls = %d, want 1", repo.queryCalls)
|
|
}
|
|
}
|
|
|
|
func TestPaymentCallbackPendingDoesNotFulfill(t *testing.T) {
|
|
pending := successPaymentResult()
|
|
pending.Status = "pending"
|
|
repo := &paymentRepoStub{callback: successPaymentResult(), query: pending}
|
|
handler := &paymentFulfillmentStub{}
|
|
uc := newPaymentUsecaseForTest(repo)
|
|
registry := NewPaymentFulfillmentRegistry()
|
|
if err := registry.Register(handler); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
uc.SetFulfillmentRegistry(registry)
|
|
result, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Status != "pending" || handler.calls != 0 {
|
|
t.Fatalf("result=%+v handler=%d", result, handler.calls)
|
|
}
|
|
}
|
|
|
|
func TestPaymentCallbackRejectsAmountMismatch(t *testing.T) {
|
|
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
|
handler := &paymentFulfillmentStub{}
|
|
uc := newPaymentUsecaseForTest(repo)
|
|
uc.orders.(*paymentOrderRepoStub).order.Amount = 101
|
|
registry := NewPaymentFulfillmentRegistry()
|
|
_ = registry.Register(handler)
|
|
uc.SetFulfillmentRegistry(registry)
|
|
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
|
|
t.Fatal("amount mismatch was accepted")
|
|
}
|
|
if handler.calls != 0 {
|
|
t.Fatal("handler ran after amount mismatch")
|
|
}
|
|
}
|
|
|
|
func TestPaymentCallbackRequiresFulfillmentHandler(t *testing.T) {
|
|
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
|
uc := newPaymentUsecaseForTest(repo)
|
|
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
|
|
t.Fatal("callback succeeded without fulfillment handler")
|
|
}
|
|
}
|
|
|
|
func TestPaymentCallbackDuplicateDoesNotFulfillTwice(t *testing.T) {
|
|
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
|
handler := &paymentFulfillmentStub{}
|
|
uc := newPaymentUsecaseForTest(repo)
|
|
registry := NewPaymentFulfillmentRegistry()
|
|
_ = registry.Register(handler)
|
|
uc.SetFulfillmentRegistry(registry)
|
|
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
|
|
if _, err := uc.Callback(context.Background(), callback); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := uc.Callback(context.Background(), callback)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !second.Duplicate || handler.calls != 1 || repo.queryCalls != 2 {
|
|
t.Fatalf("duplicate=%v handler=%d query=%d", second.Duplicate, handler.calls, repo.queryCalls)
|
|
}
|
|
}
|
|
|
|
func TestPaymentCallbackFulfillmentFailureCanRetry(t *testing.T) {
|
|
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
|
handler := &paymentFulfillmentStub{failFirst: true}
|
|
uc := newPaymentUsecaseForTest(repo)
|
|
registry := NewPaymentFulfillmentRegistry()
|
|
_ = registry.Register(handler)
|
|
uc.SetFulfillmentRegistry(registry)
|
|
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
|
|
if _, err := uc.Callback(context.Background(), callback); err == nil {
|
|
t.Fatal("first fulfillment failure was accepted")
|
|
}
|
|
result, err := uc.Callback(context.Background(), callback)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Status != "fulfilled" || handler.calls != 2 || handler.confirmIDs[0] != handler.confirmIDs[1] {
|
|
t.Fatalf("result=%+v handler=%d ids=%v", result, handler.calls, handler.confirmIDs)
|
|
}
|
|
}
|
|
|
|
func TestPaymentQueryRejectsIncompleteSuccess(t *testing.T) {
|
|
result := successPaymentResult()
|
|
result.ProviderTradeNo = ""
|
|
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
|
|
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
|
|
t.Fatal("incomplete successful query was accepted")
|
|
}
|
|
}
|
|
|
|
func TestPaymentQueryRejectsUnknownStatus(t *testing.T) {
|
|
result := successPaymentResult()
|
|
result.Status = "maybe"
|
|
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
|
|
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
|
|
t.Fatal("unknown payment status was accepted")
|
|
}
|
|
}
|
|
|
|
func TestPaymentCallbackErrorCarriesSafeAck(t *testing.T) {
|
|
ack := DefaultPaymentCallbackAck(PaymentAlipay, false)
|
|
err := callbackResultError(&PaymentResult{FailureAck: ack}, errors.New("internal order resolver details"))
|
|
callbackErr, ok := err.(*PaymentCallbackError)
|
|
if !ok || string(callbackErr.Ack.Body) != "failure" {
|
|
t.Fatalf("callback error = %#v", err)
|
|
}
|
|
}
|
|
|
|
func TestPersistentPaymentCreateUsesBusinessCanonicalAmount(t *testing.T) {
|
|
repo := &paymentRepoStub{create: &PaymentResult{Provider: PaymentAlipay, Status: "created", TradeNo: "order-1", Payload: []byte(`{"prepay":"x"}`)}}
|
|
orders := &paymentOrderRepoStub{}
|
|
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
sources := NewPaymentOrderSourceRegistry()
|
|
if err := sources.Register(&paymentSourceStub{intent: &PaymentIntent{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "canonical item", Amount: 100, Currency: "CNY", Extra: []byte(`{"product_id":"canonical"}`)}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
uc.SetOrderSourceRegistry(sources)
|
|
result, err := uc.Create(context.Background(), &PaymentRequest{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "forged", Amount: 1, Currency: "USD", Extra: map[string]any{"product_id": "forged"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repo.createdReq.Amount != 100 || repo.createdReq.Currency != "CNY" || repo.createdReq.Subject != "canonical item" || repo.createdReq.Extra["product_id"] != "canonical" || orders.order.Amount != 100 || result.OrderStatus != PaymentStatusPending {
|
|
t.Fatalf("canonical order was not used: request=%+v order=%+v result=%+v", repo.createdReq, orders.order, result)
|
|
}
|
|
}
|
|
|
|
func TestPersistentPaymentCallbackFulfillsAndPersistsDuplicate(t *testing.T) {
|
|
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
|
orders := &paymentOrderRepoStub{order: &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}}
|
|
handler := &paymentFulfillmentStub{}
|
|
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
registry := NewPaymentFulfillmentRegistry()
|
|
if err := registry.Register(handler); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
uc.SetFulfillmentRegistry(registry)
|
|
first, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.Status != "fulfilled" || second.Status != "fulfilled" || !second.Duplicate || handler.calls != 1 || orders.order.FulfillmentStatus != FulfillmentStatusSucceeded {
|
|
t.Fatalf("persistent callback flow failed: first=%+v second=%+v handler=%d order=%+v", first, second, handler.calls, orders.order)
|
|
}
|
|
}
|
|
|
|
func TestPaymentFulfillRetriesFailedOrder(t *testing.T) {
|
|
orders := &paymentOrderRepoStub{order: &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPaid, FulfillmentStatus: FulfillmentStatusFailed, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}}
|
|
uc := NewPaymentUsecase(&paymentRepoStub{}, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
handler := &paymentFulfillmentStub{}
|
|
registry := NewPaymentFulfillmentRegistry()
|
|
if err := registry.Register(handler); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
uc.SetFulfillmentRegistry(registry)
|
|
result, err := uc.Fulfill(context.Background(), PaymentAlipay, "order-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Status != "fulfilled" || handler.calls != 1 || orders.order.FulfillmentStatus != FulfillmentStatusSucceeded {
|
|
t.Fatalf("fulfillment retry failed: result=%+v calls=%d status=%s", result, handler.calls, orders.order.FulfillmentStatus)
|
|
}
|
|
}
|
|
|
|
func TestPaymentConfirmationAcceptsProviderDiscount(t *testing.T) {
|
|
intent := &PaymentIntent{Provider: PaymentAlipay, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
|
|
result := &PaymentResult{Provider: PaymentAlipay, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 200, ProviderDiscountAmount: 150, MerchantDiscountAmount: 50, SettlementAmount: 950, Currency: "CNY", AmountBreakdownKnown: true}
|
|
if err := validatePaymentConfirmation(intent, PaymentAlipay, "T1", result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestPaymentConfirmationRejectsOverpayment(t *testing.T) {
|
|
intent := &PaymentIntent{Provider: PaymentWechatV3, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
|
|
result := &PaymentResult{Provider: PaymentWechatV3, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 1001, CashPaidAmount: 1001, Currency: "CNY", AmountBreakdownKnown: true}
|
|
if err := validatePaymentConfirmation(intent, PaymentWechatV3, "T1", result); err == nil {
|
|
t.Fatal("overpayment was accepted")
|
|
}
|
|
}
|
|
|
|
func TestPaymentConfirmationRejectsBrokenDiscountConservation(t *testing.T) {
|
|
intent := &PaymentIntent{Provider: PaymentWechatV2, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
|
|
result := &PaymentResult{Provider: PaymentWechatV2, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 100, Currency: "CNY", AmountBreakdownKnown: true}
|
|
if err := validatePaymentConfirmation(intent, PaymentWechatV2, "T1", result); err == nil {
|
|
t.Fatal("broken discount conservation was accepted")
|
|
}
|
|
}
|
|
|
|
func TestPaymentConfirmationBindsAppleProductID(t *testing.T) {
|
|
intent := &PaymentIntent{
|
|
Provider: PaymentApple, TradeNo: "123e4567-e89b-12d3-a456-426614174000",
|
|
Amount: 100, Currency: "USD", BusinessType: "item", BusinessID: "I1",
|
|
Extra: json.RawMessage(`{"product_id":"com.example.coin100"}`),
|
|
}
|
|
result := &PaymentResult{
|
|
Provider: PaymentApple, TradeNo: intent.TradeNo, ProviderTradeNo: "APPLE-TX-1",
|
|
Status: "success", Amount: 100, Currency: "USD",
|
|
Payload: json.RawMessage(`{"productId":"com.example.coin100"}`),
|
|
}
|
|
if err := validatePaymentConfirmation(intent, PaymentApple, intent.TradeNo, result); err != nil {
|
|
t.Fatalf("matching productId rejected: %v", err)
|
|
}
|
|
result.Payload = json.RawMessage(`{"productId":"com.example.coin200"}`)
|
|
if err := validatePaymentConfirmation(intent, PaymentApple, intent.TradeNo, result); err == nil {
|
|
t.Fatal("mismatched Apple productId was accepted")
|
|
}
|
|
}
|