feat(v2): Stripe adapter 落地 RefundingProvider(session→payment_intent→Refunds.New,refund_id 作幂等键)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user