package payment import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "kra/internal/modules/system/biz" "github.com/go-pay/gopay/paypal" ) func TestPayPalCreateAndQueryKeepOrderAndCaptureIDsSeparate(t *testing.T) { var createBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`)) case r.Method == http.MethodPost && r.URL.Path == "/v2/checkout/orders": if got := r.Header.Get("PayPal-Request-Id"); got != "LOCAL-1" { t.Errorf("PayPal-Request-Id = %q, want LOCAL-1", got) } if err := json.NewDecoder(r.Body).Decode(&createBody); err != nil { t.Errorf("decode create body: %v", err) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{ "id":"ORDER-1","status":"CREATED", "purchase_units":[{"reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1","amount":{"currency_code":"USD","value":"10.99"}}], "links":[{"rel":"approve","href":"https://example.test/approve","method":"GET"}] }`)) case r.Method == http.MethodGet && r.URL.Path == "/v2/checkout/orders/ORDER-1": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "id":"ORDER-1","status":"COMPLETED", "purchase_units":[{ "reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1", "amount":{"currency_code":"USD","value":"10.99"}, "payments":{"captures":[{"id":"CAPTURE-1","status":"COMPLETED","amount":{"currency_code":"USD","value":"10.99"}}]} }] }`)) default: http.NotFound(w, r) } })) defer server.Close() adapter := &paypalAdapter{} config := paypalTestConfig(server.URL) created, err := adapter.Create(context.Background(), &biz.PaymentRequest{ TradeNo: "LOCAL-1", Subject: "Order one", Amount: 1099, Currency: "USD", ReturnURL: "https://merchant.test/return", Extra: map[string]any{"payment_source": map[string]any{"paypal": map[string]any{}}}, }, config) if err != nil { t.Fatalf("Create() error = %v", err) } if created.TradeNo != "LOCAL-1" || created.QueryID != "ORDER-1" || created.ProviderTradeNo != "" || created.Status != "pending" { t.Fatalf("Create() result = %+v", created) } units, _ := createBody["purchase_units"].([]any) if len(units) != 1 { t.Fatalf("purchase_units = %#v", createBody["purchase_units"]) } unit, _ := units[0].(map[string]any) amount, _ := unit["amount"].(map[string]any) if unit["invoice_id"] != "LOCAL-1" || amount["value"] != "10.99" { t.Fatalf("purchase unit = %#v", unit) } if _, ok := createBody["payment_source"]; !ok { t.Fatalf("payment_source was not forwarded: %#v", createBody) } queried, err := adapter.Query(context.Background(), created.QueryID, config) if err != nil { t.Fatalf("Query() error = %v", err) } if queried.Status != "success" || queried.TradeNo != "LOCAL-1" || queried.QueryID != "ORDER-1" || queried.ProviderTradeNo != "CAPTURE-1" || queried.Amount != 1099 || queried.Currency != "USD" { t.Fatalf("Query() result = %+v", queried) } } func TestPayPalCreateRejectsMismatchedResponseIdentityAndAmount(t *testing.T) { for _, tc := range []struct { name string response string want string }{ { name: "merchant identity", response: `{ "id":"ORDER-MISMATCH","status":"CREATED", "purchase_units":[{"reference_id":"OTHER","invoice_id":"OTHER","custom_id":"OTHER","amount":{"currency_code":"USD","value":"10.99"}}] }`, want: "商户订单号不匹配", }, { name: "conflicting merchant identities", response: `{ "id":"ORDER-CONFLICT","status":"CREATED", "purchase_units":[{"reference_id":"LOCAL-1","invoice_id":"OTHER","amount":{"currency_code":"USD","value":"10.99"}}] }`, want: "冲突的商户订单号", }, { name: "amount", response: `{ "id":"ORDER-AMOUNT","status":"CREATED", "purchase_units":[{"reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1","amount":{"currency_code":"USD","value":"11.00"}}] }`, want: "金额不匹配", }, { name: "currency", response: `{ "id":"ORDER-CURRENCY","status":"CREATED", "purchase_units":[{"reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1","amount":{"currency_code":"EUR","value":"10.99"}}] }`, want: "币种不匹配", }, } { t.Run(tc.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`)) case r.Method == http.MethodPost && r.URL.Path == "/v2/checkout/orders": w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(tc.response)) default: http.NotFound(w, r) } })) defer server.Close() _, err := (&paypalAdapter{}).Create(context.Background(), &biz.PaymentRequest{ TradeNo: "LOCAL-1", Subject: "Order one", Amount: 1099, Currency: "USD", }, paypalTestConfig(server.URL)) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("Create() error = %v, want %q", err, tc.want) } }) } } func TestPayPalQueryCapturesApprovedOrderBeforeFulfillment(t *testing.T) { var captureCalled bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`)) case r.Method == http.MethodGet && r.URL.Path == "/v2/checkout/orders/ORDER-APPROVED": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "id":"ORDER-APPROVED","status":"APPROVED", "purchase_units":[{"reference_id":"LOCAL-APPROVED","invoice_id":"LOCAL-APPROVED","custom_id":"LOCAL-APPROVED","amount":{"currency_code":"USD","value":"10.99"}}] }`)) case r.Method == http.MethodPost && r.URL.Path == "/v2/checkout/orders/ORDER-APPROVED/capture": captureCalled = true if got := r.Header.Get("PayPal-Request-Id"); got != "ORDER-APPROVED-capture" { t.Errorf("capture PayPal-Request-Id = %q", got) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{ "id":"ORDER-APPROVED","status":"COMPLETED", "purchase_units":[{"reference_id":"LOCAL-APPROVED","invoice_id":"LOCAL-APPROVED","custom_id":"LOCAL-APPROVED","amount":{"currency_code":"USD","value":"10.99"}, "payments":{"captures":[{"id":"CAPTURE-APPROVED","status":"COMPLETED","amount":{"currency_code":"USD","value":"10.99"}}]}}] }`)) default: http.NotFound(w, r) } })) defer server.Close() result, err := (&paypalAdapter{}).Query(context.Background(), "ORDER-APPROVED", paypalTestConfig(server.URL)) if err != nil { t.Fatalf("Query() error = %v", err) } if !captureCalled { t.Fatal("Query() did not capture approved order") } if result.Status != "success" || result.TradeNo != "LOCAL-APPROVED" || result.ProviderTradeNo != "CAPTURE-APPROVED" || result.Amount != 1099 || result.Currency != "USD" { t.Fatalf("captured result = %+v", result) } } func TestPayPalOrderDoesNotFulfillPendingCapture(t *testing.T) { order := &paypal.OrderDetail{ Id: "ORDER-PENDING", Status: "COMPLETED", PurchaseUnits: []*paypal.PurchaseUnit{{ InvoiceId: "LOCAL-PENDING", Amount: &paypal.Amount{CurrencyCode: "USD", Value: "10.99"}, Payments: &paypal.Payments{Captures: []*paypal.Capture{{ Id: "CAPTURE-PENDING", Status: "PENDING", Amount: &paypal.Amount{CurrencyCode: "USD", Value: "10.99"}, }}}, }}, } result, err := paypalOrderResult(order, order.Id, "", map[string]any{"amount_scales": map[string]any{"USD": "100"}}) if err != nil { t.Fatal(err) } if result.Status != "pending" || result.ProviderTradeNo != "" { t.Fatalf("pending capture result = %+v", result) } } func TestPayPalRefundUsesPersistedCaptureID(t *testing.T) { var refundBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`)) case r.Method == http.MethodPost && r.URL.Path == "/v2/payments/captures/CAPTURE-1/refund": if got := r.Header.Get("PayPal-Request-Id"); got != "REFUND-1" { t.Errorf("PayPal-Request-Id = %q, want REFUND-1", got) } if err := json.NewDecoder(r.Body).Decode(&refundBody); err != nil { t.Errorf("decode refund body: %v", err) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"id":"PAYPAL-REFUND-1","invoice_id":"REFUND-1","status":"PENDING","amount":{"currency_code":"USD","value":"1.23"}}`)) default: http.NotFound(w, r) } })) defer server.Close() config := paypalTestConfig(server.URL) config["provider_trade_no"] = "WRONG-CONFIG-CAPTURE" result, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{ Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", ProviderTradeNo: "CAPTURE-1", QueryID: "ORDER-1", RefundNo: "REFUND-1", Amount: 123, TotalAmount: 1099, Currency: "USD", }, config) if err != nil { t.Fatalf("Refund() error = %v", err) } if result.TradeNo != "LOCAL-1" || result.ProviderTradeNo != "PAYPAL-REFUND-1" || result.Amount != 123 || result.Currency != "USD" || result.Status != "pending" { t.Fatalf("Refund() result = %+v", result) } amount, _ := refundBody["amount"].(map[string]any) if refundBody["invoice_id"] != "REFUND-1" || amount["value"] != "1.23" { t.Fatalf("refund body = %#v", refundBody) } } func TestPayPalRefundResolvesCaptureFromPersistedQueryID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`)) case r.Method == http.MethodGet && r.URL.Path == "/v2/checkout/orders/ORDER-1": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "id":"ORDER-1","status":"COMPLETED", "purchase_units":[{ "invoice_id":"LOCAL-1", "amount":{"currency_code":"USD","value":"10.99"}, "payments":{"captures":[{"id":"CAPTURE-FROM-ORDER","status":"COMPLETED"}]} }] }`)) case r.Method == http.MethodPost && r.URL.Path == "/v2/payments/captures/CAPTURE-FROM-ORDER/refund": w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"id":"PAYPAL-REFUND-2","invoice_id":"REFUND-2","status":"COMPLETED","amount":{"currency_code":"USD","value":"1.23"}}`)) default: http.NotFound(w, r) } })) defer server.Close() config := paypalTestConfig(server.URL) config["capture_id"] = "WRONG-CONFIG-CAPTURE" result, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{ Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", QueryID: "ORDER-1", RefundNo: "REFUND-2", Amount: 123, TotalAmount: 1099, Currency: "USD", }, config) if err != nil { t.Fatalf("Refund() error = %v", err) } if result.ProviderTradeNo != "PAYPAL-REFUND-2" || result.Status != "success" { t.Fatalf("Refund() result = %+v", result) } } func TestPayPalRefundRejectsStaticCaptureIDWithoutDurableIdentity(t *testing.T) { config := paypalTestConfig("https://paypal.invalid") config["capture_id"] = "CONFIG-CAPTURE" _, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{ Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", RefundNo: "REFUND-1", Amount: 123, Currency: "USD", }, config) if err == nil { t.Fatal("Refund() accepted a static config capture ID") } } func TestPayPalRefundResultBindsRefundIdentity(t *testing.T) { req := &biz.PaymentRefundRequest{TradeNo: "LOCAL-1", RefundNo: "REFUND-1", Amount: 123, Currency: "USD"} base := func() *paypal.PaymentCaptureRefund { return &paypal.PaymentCaptureRefund{ Id: "PAYPAL-REFUND-1", InvoiceId: "REFUND-1", Status: "PENDING", Amount: &paypal.Amount{CurrencyCode: "USD", Value: "1.23"}, } } for _, tc := range []struct { name string mutate func(*paypal.PaymentCaptureRefund) }{ {name: "missing invoice id", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.InvoiceId = "" }}, {name: "mismatched invoice id", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.InvoiceId = "OTHER" }}, {name: "missing provider refund id", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.Id = "" }}, {name: "missing amount", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.Amount = nil }}, {name: "mismatched amount", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.Amount.Value = "1.24" }}, {name: "mismatched currency", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.Amount.CurrencyCode = "EUR" }}, } { t.Run(tc.name, func(t *testing.T) { refund := base() tc.mutate(refund) if _, err := paypalRefundResult(req, refund, nil); err == nil { t.Fatal("paypalRefundResult() accepted an unbound refund response") } }) } } func TestPayPalCallbackUsesGoPayWebhookVerification(t *testing.T) { var verifyBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`)) case r.Method == http.MethodPost && r.URL.Path == "/v1/notifications/verify-webhook-signature": if err := json.NewDecoder(r.Body).Decode(&verifyBody); err != nil { t.Errorf("decode verify body: %v", err) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"verification_status":"SUCCESS"}`)) default: http.NotFound(w, r) } })) defer server.Close() body := []byte(`{ "id":"WEBHOOK-1","event_type":"PAYMENT.CAPTURE.COMPLETED", "resource":{ "id":"CAPTURE-1","status":"COMPLETED","invoice_id":"LOCAL-1", "supplementary_data":{"related_ids":{"order_id":"ORDER-1"}} } }`) config := paypalTestConfig(server.URL) config["webhook_id"] = "HOOK-1" result, err := (&paypalAdapter{}).Callback(context.Background(), &biz.PaymentCallback{ Body: body, Headers: map[string]string{ "Paypal-Auth-Algo": "SHA256withRSA", "Paypal-Cert-Url": "https://api.paypal.test/cert", "Paypal-Transmission-Id": "TRANSMISSION-1", "Paypal-Transmission-Sig": "SIGNATURE", "Paypal-Transmission-Time": "2026-08-19T00:00:00Z", }, }, config) if err != nil { t.Fatalf("Callback() error = %v", err) } if result.TradeNo != "LOCAL-1" || result.QueryID != "ORDER-1" || result.ProviderTradeNo != "CAPTURE-1" || result.EventID != "WEBHOOK-1" || result.Status != "success" { t.Fatalf("Callback() result = %+v", result) } if verifyBody["webhook_id"] != "HOOK-1" || verifyBody["transmission_id"] != "TRANSMISSION-1" { t.Fatalf("verify body = %#v", verifyBody) } if _, ok := verifyBody["webhook_event"].(map[string]any); !ok { t.Fatalf("webhook_event was not sent as an object: %#v", verifyBody["webhook_event"]) } } func paypalTestConfig(baseURL string) map[string]any { return map[string]any{ "client_id": "client-id", "client_secret": "client-secret", "environment": "sandbox", "api_base_url": baseURL, "cancel_url": "https://merchant.test/cancel", "notify_url": "https://merchant.test/paypal/webhook", } }