From 0b26acac0798a43935bd6a71be260cac3b9acfe2 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sun, 2 Aug 2026 20:27:44 +0800 Subject: [PATCH] fix(payment): make Stripe refunds idempotent --- backend/internal/payment/provider/stripe.go | 1 + .../internal/payment/provider/stripe_test.go | 70 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 backend/internal/payment/provider/stripe_test.go diff --git a/backend/internal/payment/provider/stripe.go b/backend/internal/payment/provider/stripe.go index 59e31f8122..f09cc21b2d 100644 --- a/backend/internal/payment/provider/stripe.go +++ b/backend/internal/payment/provider/stripe.go @@ -230,6 +230,7 @@ func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*paymen Amount: stripe.Int64(amountInMinorUnit), Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)), } + params.SetIdempotencyKey(fmt.Sprintf("re-%s-%d", req.OrderID, amountInMinorUnit)) params.Context = ctx r, err := s.sc.V1Refunds.Create(ctx, params) diff --git a/backend/internal/payment/provider/stripe_test.go b/backend/internal/payment/provider/stripe_test.go new file mode 100644 index 0000000000..9c327898ff --- /dev/null +++ b/backend/internal/payment/provider/stripe_test.go @@ -0,0 +1,70 @@ +//go:build unit + +package provider + +import ( + "bytes" + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/stretchr/testify/require" + stripe "github.com/stripe/stripe-go/v85" +) + +type stripeRefundBackend struct { + params []*stripe.RefundCreateParams +} + +func (b *stripeRefundBackend) Call(_ string, _ string, _ string, params stripe.ParamsContainer, v stripe.LastResponseSetter) error { + b.params = append(b.params, params.(*stripe.RefundCreateParams)) + refund := v.(*stripe.Refund) + refund.ID = "re_123" + refund.Status = stripe.RefundStatusSucceeded + return nil +} + +func (*stripeRefundBackend) CallStreaming(string, string, string, stripe.ParamsContainer, stripe.StreamingLastResponseSetter) error { + return nil +} + +func (*stripeRefundBackend) CallRaw(string, string, string, []byte, *stripe.Params, stripe.LastResponseSetter) error { + return nil +} + +func (*stripeRefundBackend) CallMultipart(string, string, string, string, *bytes.Buffer, *stripe.Params, stripe.LastResponseSetter) error { + return nil +} + +func (*stripeRefundBackend) SetMaxNetworkRetries(int64) {} + +func TestStripeRefundUsesStableAmountSpecificIdempotencyKey(t *testing.T) { + backend := &stripeRefundBackend{} + client := stripe.NewClient("sk_test", stripe.WithBackends(&stripe.Backends{API: backend})) + provider := &Stripe{ + config: map[string]string{"currency": "CNY"}, + initialized: true, + sc: client, + } + + refund := func(amount string) { + _, err := provider.Refund(context.Background(), payment.RefundRequest{ + TradeNo: "pi_123", + OrderID: "sub2_order_456", + Amount: amount, + }) + require.NoError(t, err) + } + + refund("12.34") + refund("12.34") + refund("12.35") + + require.Len(t, backend.params, 3) + require.Equal(t, int64(1234), *backend.params[0].Amount) + require.Equal(t, "re-sub2_order_456-1234", *backend.params[0].IdempotencyKey) + require.Equal(t, backend.params[0].IdempotencyKey, backend.params[1].IdempotencyKey) + require.Equal(t, int64(1235), *backend.params[2].Amount) + require.Equal(t, "re-sub2_order_456-1235", *backend.params[2].IdempotencyKey) + require.NotEqual(t, *backend.params[0].IdempotencyKey, *backend.params[2].IdempotencyKey) +}