package payment 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) 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") } }