diff --git a/internal/provider/stripe/stripe.go b/internal/provider/stripe/stripe.go index bc80c30..df22362 100644 --- a/internal/provider/stripe/stripe.go +++ b/internal/provider/stripe/stripe.go @@ -42,12 +42,45 @@ func (p *Provider) Method() string { return "stripe" } func (p *Provider) Capabilities() provider.Capabilities { return provider.Capabilities{ RenderTypes: []provider.RenderType{provider.RenderRedirect}, - SupportsRefund: false, // P4 + SupportsRefund: true, // P4:/v1/refunds SettleCurrencies: []string{supportedCurrency}, Regions: []string{"global"}, } } +// Refund 经 Checkout Session 取 PaymentIntent 再退款。refundID 作 Idempotency-Key。 +// 不传 Stripe Reason(仅收枚举);业务文案只落 pay 本地。 +func (p *Provider) Refund(_ context.Context, providerRef, refundID string, amountMinor int64, _ string) (string, provider.PaidStatus, error) { + sess, err := p.sc.CheckoutSessions.Get(providerRef, nil) + if err != nil { + return "", provider.PaidFailed, fmt.Errorf("stripe: 取 session 失败: %w", err) + } + if sess.PaymentIntent == nil || sess.PaymentIntent.ID == "" { + return "", provider.PaidFailed, fmt.Errorf("stripe: session %s 无 payment_intent,无法退款", providerRef) + } + params := &gostripe.RefundParams{ + PaymentIntent: gostripe.String(sess.PaymentIntent.ID), + Amount: gostripe.Int64(amountMinor), // cent = USD minor,直传 + } + params.SetIdempotencyKey(refundID) + rf, err := p.sc.Refunds.New(params) + if err != nil { + return "", provider.PaidFailed, fmt.Errorf("stripe: 退款请求失败: %w", err) + } + return rf.ID, mapRefundStatus(rf.Status), nil +} + +func mapRefundStatus(s gostripe.RefundStatus) provider.PaidStatus { + switch s { + case gostripe.RefundStatusSucceeded: + return provider.PaidSucceeded + case gostripe.RefundStatusFailed, gostripe.RefundStatusCanceled: + return provider.PaidFailed + default: // pending / requires_action → 异步,交上层保持 processing + return provider.PaidPending + } +} + func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { if req.Currency != supportedCurrency { return nil, fmt.Errorf("stripe: 仅支持 %s, got %s", supportedCurrency, req.Currency) diff --git a/internal/provider/stripe/stripe_refund_test.go b/internal/provider/stripe/stripe_refund_test.go new file mode 100644 index 0000000..ff56328 --- /dev/null +++ b/internal/provider/stripe/stripe_refund_test.go @@ -0,0 +1,55 @@ +package stripe_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gostripe "github.com/stripe/stripe-go/v79" + "github.com/stripe/stripe-go/v79/client" + + "github.com/wangjia/pay/internal/provider" + st "github.com/wangjia/pay/internal/provider/stripe" +) + +func fakeStripeRefundAPI(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/checkout/sessions/cs_test_123"): + // 未展开:payment_intent 是字符串 id,SDK 反序列化进 *PaymentIntent{ID} + fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","payment_intent":"pi_test_456","amount_total":2999,"currency":"usd","payment_status":"paid"}`) + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/refunds"): + fmt.Fprint(w, `{"id":"re_test_789","object":"refund","amount":2999,"currency":"usd","payment_intent":"pi_test_456","status":"succeeded"}`) + default: + http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound) + } + })) +} + +func newStripeRefund(t *testing.T, ts *httptest.Server) *st.Provider { + backends := &gostripe.Backends{ + API: gostripe.GetBackendWithConfig(gostripe.APIBackend, &gostripe.BackendConfig{URL: gostripe.String(ts.URL)}), + } + return st.New(client.New("sk_test_x", backends), "whsec_test_secret") +} + +func TestStripeRefundSuccess(t *testing.T) { + ts := fakeStripeRefundAPI(t) + defer ts.Close() + p := newStripeRefund(t, ts) + + if !p.Capabilities().SupportsRefund { + t.Fatal("stripe Capabilities.SupportsRefund 应为 true") + } + ref, status, err := p.Refund(context.Background(), "cs_test_123", "rf-999", 2999, "requested_by_customer") + if err != nil { + t.Fatalf("refund: %v", err) + } + if status != provider.PaidSucceeded || ref != "re_test_789" { + t.Fatalf("refund result: ref=%s status=%s", ref, status) + } +}