fix(hyperswitch): idempotency-key on create-payment and create-refund — v1.0.7 item D

Every outbound POST /payments and POST /refunds from the Hyperswitch
client now carries an Idempotency-Key HTTP header. Key values are
explicit parameters at every call site — no context-carrier magic,
no auto-generation. An empty key is a loud error from the client
(not silent header omission) so a future new call site that forgets
to supply one fails immediately, not months later under an obscure
replay scenario.

Key choices, both stable across HTTP retries of the same logical
call:
  * CreatePayment → order.ID.String() (GORM BeforeCreate populates
    order.ID before the PSP call in ConfirmOrder).
  * CreateRefund → pendingRefund.ID.String() (populated by the
    Phase 1 tx.Create in RefundOrder, available for the Phase 2 PSP
    call).

Scope note (reproduced here for the next reader who grep-s the
commit log for "Idempotency-Key"):

  Idempotency-Key covers HTTP-transport retry (TLS reconnect,
  proxy retry, DNS flap) within a single CreatePayment /
  CreateRefund invocation. It does NOT cover application-level
  replay (user double-click, form double-submit, retry after crash
  before DB write). That class of bug requires state-machine
  preconditions on VEZA side — already addressed by the order
  state machine + the handler-level guards on POST
  /api/v1/payments (for payments) and the partial UNIQUE on
  `refunds.hyperswitch_refund_id` landed in v1.0.6.1 (for refunds).

  Hyperswitch TTL on Idempotency-Key: typically 24h-7d server-side
  (verify against current PSP docs). Beyond TTL, a retry with the
  same key is treated as a new request. Not a concern at current
  volumes; document if retry logic ever extends beyond 1 hour.

Explicitly out of scope: item D does NOT add application-level
retry logic. The current "try once, fail loudly" behavior on PSP
errors is preserved. Adding retries is a separate design exercise
(backoff, max attempts, circuit breaker) not part of this commit.

Interfaces changed:
  * hyperswitch.Client.CreatePayment(ctx, idempotencyKey, ...)
  * hyperswitch.Client.CreatePaymentSimple(...) convenience wrapper
  * hyperswitch.Client.CreateRefund(ctx, idempotencyKey, ...)
  * hyperswitch.Provider.CreatePayment threads through
  * hyperswitch.Provider.CreateRefund threads through
  * marketplace.PaymentProvider interface — first param after ctx
  * marketplace.refundProvider interface — first param after ctx

Removed:
  * hyperswitch.Provider.Refund (zero callers, superseded by
    CreateRefund which returns (refund_id, status, err) and is the
    only method marketplace's refundProvider cares about).

Tests:
  * Two new httptest.Server-backed tests (client_test.go) pin the
    Idempotency-Key header value for CreatePayment and CreateRefund.
  * Two new empty-key tests confirm the client errors rather than
    silently sending no header.
  * TestRefundOrder_OpensPendingRefund gains an assertion that
    f.provider.lastIdempotencyKey == refund.ID.String() — if a
    future refactor threads the key from somewhere else (paymentID,
    uuid.New() per call, etc.) the test fails loudly.
  * Four pre-existing test mocks updated for the new signature
    (mockRefundPaymentProvider in marketplace, mockPaymentProvider
    in tests/integration and tests/contract, mockRefundPayment
    Provider in tests/integration/refund_flow).

Subscription's CreateSubscriptionPayment interface declares its own
shape and has no live Hyperswitch-backed implementation today —
v1.0.6.2 noted this as the payment-gate bypass surface, v1.0.7
item G will ship the real provider. When that lands, item G's
implementation threads the idempotency key through in the same
pattern (documented in v107-plan.md item G acceptance).

CHANGELOG v1.0.7-rc1 entry updated with the full item D scope note
and the "out of scope: retries" caveat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-18 02:30:02 +02:00
parent 1a133af9ac
commit 3cd82ba5be
10 changed files with 240 additions and 38 deletions

View file

@ -38,6 +38,62 @@ auto-reversed; the backfill CLI queries Stripe's transfers.List by
metadata[order_id] to populate missing ids, acceptable to leave NULL
per v107-plan.
### Item D — Idempotency-Key on CreatePayment / CreateRefund
The Hyperswitch client now sends an `Idempotency-Key` HTTP header on
every outbound POST /payments and POST /refunds. The header value is
an explicit parameter at every call site — no context-carrier magic,
no auto-generation — so the contract is visible in every call and
impossible to forget (empty keys cause a loud error, not silent
header omission).
Key values:
* CreatePayment → `order.ID.String()` (UUID generated by GORM
BeforeCreate before the HTTP call).
* CreateRefund → `pendingRefund.ID.String()` (same pattern — UUID
populated by the Phase 1 tx.Create in RefundOrder, available and
stable for the Phase 2 PSP call).
Scope (load-bearing note for future readers):
`Idempotency-Key` covers HTTP-transport retry (TLS reconnect,
proxy retry, DNS flap) within a single CreatePayment /
CreateRefund invocation. It does NOT cover application-level
replay (user double-click, form double-submit, retry after crash
before DB write). That class of bug requires state-machine
preconditions on VEZA side — already addressed by the order
state machine + checkout handler guards (for payments) and the
partial UNIQUE on `refunds.hyperswitch_refund_id` landed in
v1.0.6.1 (for refunds).
Hyperswitch TTL on Idempotency-Key is typically 24h7d
server-side (verify against current PSP docs). Beyond TTL, a
retry with the same key is treated as a new request. Not a
concern at current volumes; document if retry logic ever extends
beyond 1 hour.
What stays unchanged: this commit does NOT add application-level
retry logic. The current "try once, fail loudly" behavior on PSP
errors is preserved. Adding retries is a separate design exercise
(backoff, max attempts, circuit breaker) explicitly out of scope
for item D.
Tests:
* Two httptest.Server-backed tests in client_test.go pin the
header value emitted for CreatePayment and CreateRefund, plus
two tests asserting empty keys cause a loud error.
* TestRefundOrder_OpensPendingRefund now pins the
`refund.ID.String() == lastIdempotencyKey` contract so a
future refactor that drops or reshapes the key fails the test.
* Four existing test mocks updated for the new signature.
Subscription's CreateSubscriptionPayment interface also takes a
payment provider but no implementation is wired in today (v1.0.6.2
noted this as the bypass surface, v1.0.7 item G is the full fix).
When item G lands its Hyperswitch-backed subscription provider,
it will need to thread the idempotency key through the same way —
noted in item G's acceptance in v107-plan.md.
### Item B — async Stripe Connect reversal worker
`reverseSellerAccounting` moved from synchronous "mark row reversed

View file

@ -84,14 +84,21 @@ Effort: **XS**. Pure header addition. Tests: the 15-case refund suite
already exists; add 2 cases verifying the header is set correctly
(httptest.Server assertion on `r.Header.Get("Idempotency-Key")`).
Acceptance:
Acceptance (landed in commit TBD — this entry pinned ahead):
- Every outbound `POST /payments` carries `Idempotency-Key: <order.id>`.
- Every outbound `POST /refunds` carries `Idempotency-Key: <refund.id>`.
- No implicit-via-ctx magic: each call site sets the header explicitly,
greppable.
- Empty idempotency key returns an error from the client (loud failure,
not silent header omission).
- CHANGELOG entry cross-references P0.4 + its scope note (HTTP retry
only, not app-level replay).
**Status** — landed 2026-04-18 alongside item B day 3 closure.
Subscription's CreateSubscriptionPayment interface still lacks a live
Hyperswitch impl (deferred to item G); that's where the remaining
idempotency-key plumbing goes.
**TTL caveat** — Hyperswitch (like most PSPs) honours `Idempotency-Key`
server-side only for a finite window: 24 h is common, 7 d at the high
end. Beyond the TTL, a replayed call with the same key is treated as

View file

@ -36,15 +36,17 @@ func setupRefundTestDB(t *testing.T) *gorm.DB {
}
// mockRefundPaymentProvider implements both PaymentProvider and
// refundProvider (v1.0.6: CreateRefund returns refund_id + status).
// refundProvider (v1.0.6: CreateRefund returns refund_id + status,
// v1.0.7 item D: CreateRefund captures idempotencyKey for assertion).
type mockRefundPaymentProvider struct {
refundErr error
refundIDCount atomic.Int32
lastAmount *int64
lastReason string
refundErr error
refundIDCount atomic.Int32
lastAmount *int64
lastReason string
lastIdempotencyKey string
}
func (m *mockRefundPaymentProvider) CreatePayment(_ context.Context, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) {
func (m *mockRefundPaymentProvider) CreatePayment(_ context.Context, _ string, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) {
return "pay_mock", "secret_mock", nil
}
@ -52,7 +54,8 @@ func (m *mockRefundPaymentProvider) GetPayment(_ context.Context, _ string) (str
return "succeeded", nil
}
func (m *mockRefundPaymentProvider) CreateRefund(_ context.Context, _ string, amount *int64, reason string) (string, string, error) {
func (m *mockRefundPaymentProvider) CreateRefund(_ context.Context, idempotencyKey, _ string, amount *int64, reason string) (string, string, error) {
m.lastIdempotencyKey = idempotencyKey
if m.refundErr != nil {
return "", "", m.refundErr
}
@ -166,6 +169,14 @@ func TestRefundOrder_OpensPendingRefund(t *testing.T) {
var persisted Refund
require.NoError(t, f.db.First(&persisted, refund.ID).Error)
assert.Equal(t, refund.HyperswitchRefundID, persisted.HyperswitchRefundID)
// v1.0.7 item D: the pending Refund row's own UUID is what gets sent
// as Idempotency-Key to Hyperswitch. This test pins the contract so
// a future refactor that drops the key, falls back to a different
// value (like paymentID), or uses uuid.New() per retry surfaces
// immediately.
assert.Equal(t, refund.ID.String(), f.provider.lastIdempotencyKey,
"Idempotency-Key must be refund.ID (stable across HTTP-layer retries of the same logical refund)")
}
func TestRefundOrder_PSPErrorRollsBack(t *testing.T) {

View file

@ -42,8 +42,29 @@ type CreateOrderResponse struct {
}
// PaymentProvider defines the interface for payment processing (Hyperswitch).
//
// v1.0.7 item D: CreatePayment takes an explicit `idempotencyKey`
// (first parameter after ctx) that the provider sends as the
// `Idempotency-Key` HTTP header. Callers supply a UUID stable across
// retries of the same logical call — for checkout, that's the order
// ID generated by GORM's BeforeCreate hook before the HTTP call. A
// provider that receives an empty key is expected to error, so a
// future new call site that forgets to supply one fails loudly.
//
// Scope note: idempotencyKey covers HTTP-transport retry (TLS
// reconnect, proxy retry, DNS flap) within a single CreatePayment
// invocation. It does NOT cover application-level replay (user
// double-click, form double-submit, retry after crash before DB
// write). That class of bug requires state-machine preconditions on
// VEZA side — already addressed by the order state machine + the
// handler-level guards on POST /api/v1/payments.
//
// Hyperswitch TTL on Idempotency-Key: typically 24h7d server-side
// (verify against current docs). Beyond TTL, a retry with the same
// key is treated as a new request. Not a concern at current volumes,
// noted here for any future extension of retry windows.
type PaymentProvider interface {
CreatePayment(ctx context.Context, amount int64, currency, orderID, returnURL string, metadata map[string]string) (paymentID, clientSecret string, err error)
CreatePayment(ctx context.Context, idempotencyKey string, amount int64, currency, orderID, returnURL string, metadata map[string]string) (paymentID, clientSecret string, err error)
GetPayment(ctx context.Context, paymentID string) (status string, err error)
}
@ -512,7 +533,11 @@ func (s *Service) CreateOrder(ctx context.Context, buyerID uuid.UUID, items []Ne
}
returnURL := baseURL + sep + "order_id=" + order.ID.String()
var err error
paymentID, clientSecret, err = s.paymentProvider.CreatePayment(ctx, int64(amountCents), "EUR", order.ID.String(), returnURL, map[string]string{"order_id": order.ID.String()})
// v1.0.7 item D: order.ID is populated at struct construction
// (via GORM BeforeCreate) before this call, so the key is
// stable across any HTTP-transport retry of this single
// CreatePayment invocation.
paymentID, clientSecret, err = s.paymentProvider.CreatePayment(ctx, order.ID.String(), int64(amountCents), "EUR", order.ID.String(), returnURL, map[string]string{"order_id": order.ID.String()})
if err != nil {
s.logger.Error("Hyperswitch CreatePayment failed", zap.Error(err), zap.String("order_id", order.ID.String()))
return fmt.Errorf("payment creation failed: %w", err)
@ -1237,8 +1262,12 @@ func (s *Service) ListReviews(ctx context.Context, productID uuid.UUID, limit, o
// service can persist the correlation key used by the webhook handler for
// idempotent finalization. The status is informational — we never trust a
// synchronous "succeeded" response, we always wait for the webhook.
//
// v1.0.7 item D: idempotencyKey is the pending Refund row's UUID,
// stable across HTTP retries of the same logical call. Sent as
// `Idempotency-Key` header. Empty key is a loud error (see client).
type refundProvider interface {
CreateRefund(ctx context.Context, paymentID string, amount *int64, reason string) (refundID string, status string, err error)
CreateRefund(ctx context.Context, idempotencyKey, paymentID string, amount *int64, reason string) (refundID string, status string, err error)
}
var ErrOrderNotRefundable = errors.New("order cannot be refunded")
@ -1359,7 +1388,10 @@ func (s *Service) RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUI
// Phase 2: PSP call outside the transaction. Any failure here unwinds
// the pending state so the buyer can retry.
refundID, hsStatus, pspErr := rp.CreateRefund(ctx, paymentID, nil, reason)
// v1.0.7 item D: pendingRefund.ID is populated by GORM BeforeCreate
// during the Phase 1 tx.Create above, so it's available and stable
// here as the Idempotency-Key for this HTTP call.
refundID, hsStatus, pspErr := rp.CreateRefund(ctx, pendingRefund.ID.String(), paymentID, nil, reason)
if pspErr != nil {
now := time.Now().UTC()
errMsg := pspErr.Error()

View file

@ -51,7 +51,26 @@ type PaymentStatus struct {
}
// CreatePayment creates a payment in Hyperswitch and returns client_secret for frontend.
func (c *Client) CreatePayment(ctx context.Context, amount int64, currency, orderID, returnURL string, metadata map[string]string) (*PaymentResponse, error) {
//
// idempotencyKey is REQUIRED (v1.0.7 item D) and sent as the
// `Idempotency-Key` header. Hyperswitch short-circuits subsequent
// requests carrying the same key (within the PSP-side TTL — typically
// 24h to 7d, verify against current docs) to the original response,
// so an HTTP-layer retry (TLS reconnect, proxy flap, DNS hiccup) on
// the same call produces at-most-once semantics. The key MUST be
// stable across retries of the same logical call — order.ID.String()
// at the site that creates orders is the canonical choice.
//
// Scope note: this header only addresses HTTP-transport retry within
// a single CreatePayment invocation. It does NOT address
// application-level replay (user double-click, form double-submit,
// retry after crash before DB write). That class of bug requires
// state-machine preconditions on VEZA side and is addressed by the
// order state machine + checkout handler's existing guards.
func (c *Client) CreatePayment(ctx context.Context, idempotencyKey string, amount int64, currency, orderID, returnURL string, metadata map[string]string) (*PaymentResponse, error) {
if idempotencyKey == "" {
return nil, fmt.Errorf("create payment: idempotency key required")
}
if metadata == nil {
metadata = make(map[string]string)
}
@ -76,6 +95,7 @@ func (c *Client) CreatePayment(ctx context.Context, amount int64, currency, orde
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("api-key", c.apiKey)
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := c.httpClient.Do(req)
if err != nil {
@ -96,8 +116,8 @@ func (c *Client) CreatePayment(ctx context.Context, amount int64, currency, orde
// CreatePaymentSimple creates a payment and returns paymentID and clientSecret.
// Convenience wrapper for PaymentProvider interface.
func (c *Client) CreatePaymentSimple(ctx context.Context, amount int64, currency, orderID, returnURL string, metadata map[string]string) (paymentID, clientSecret string, err error) {
resp, err := c.CreatePayment(ctx, amount, currency, orderID, returnURL, metadata)
func (c *Client) CreatePaymentSimple(ctx context.Context, idempotencyKey string, amount int64, currency, orderID, returnURL string, metadata map[string]string) (paymentID, clientSecret string, err error) {
resp, err := c.CreatePayment(ctx, idempotencyKey, amount, currency, orderID, returnURL, metadata)
if err != nil {
return "", "", err
}
@ -155,8 +175,19 @@ type RefundResponse struct {
Status string `json:"status"`
}
// CreateRefund creates a refund against a payment (v0.403 R2)
func (c *Client) CreateRefund(ctx context.Context, paymentID string, amount *int64, reason string) (*RefundResponse, error) {
// CreateRefund creates a refund against a payment (v0.403 R2).
//
// idempotencyKey is REQUIRED (v1.0.7 item D) and sent as the
// `Idempotency-Key` header. Canonical choice: the pending Refund
// row's ID — stable across HTTP retries of the same logical refund,
// and unique per refund attempt. Same scope caveats as CreatePayment:
// HTTP-transport-level retry only, not application-level replay.
// Application-level idempotency is guaranteed by the partial UNIQUE
// on `refunds.hyperswitch_refund_id` landed in v1.0.6.1.
func (c *Client) CreateRefund(ctx context.Context, idempotencyKey, paymentID string, amount *int64, reason string) (*RefundResponse, error) {
if idempotencyKey == "" {
return nil, fmt.Errorf("create refund: idempotency key required")
}
reqBody := CreateRefundRequest{
PaymentID: paymentID,
Amount: amount,
@ -173,6 +204,7 @@ func (c *Client) CreateRefund(ctx context.Context, paymentID string, amount *int
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("api-key", c.apiKey)
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)

View file

@ -9,6 +9,8 @@ import (
)
func TestClient_CreatePayment(t *testing.T) {
const expectedIdempKey = "order-123"
var gotIdempKey string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/payments" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
@ -16,6 +18,7 @@ func TestClient_CreatePayment(t *testing.T) {
if r.Header.Get("api-key") == "" {
t.Error("missing api-key header")
}
gotIdempKey = r.Header.Get("Idempotency-Key")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"payment_id": "pay_test_123",
@ -28,6 +31,7 @@ func TestClient_CreatePayment(t *testing.T) {
client := NewClient(server.URL, "test_api_key")
paymentID, clientSecret, err := client.CreatePaymentSimple(
context.Background(),
expectedIdempKey,
6540,
"EUR",
"order-123",
@ -43,6 +47,28 @@ func TestClient_CreatePayment(t *testing.T) {
if clientSecret != "pi_test_secret_xxx" {
t.Errorf("client_secret = %q, want pi_test_secret_xxx", clientSecret)
}
// v1.0.7 item D: the Idempotency-Key header is load-bearing — a
// future refactor that drops it from the request pipeline silently
// reopens the HTTP-retry duplicate-payment exposure. Pin the value.
if gotIdempKey != expectedIdempKey {
t.Errorf("Idempotency-Key header = %q, want %q", gotIdempKey, expectedIdempKey)
}
}
func TestClient_CreatePayment_RejectsEmptyIdempotencyKey(t *testing.T) {
// v1.0.7 item D: an empty key is an error — the caller forgot to
// supply one, and silently sending the header empty would produce
// Hyperswitch behavior indistinguishable from a no-header request.
// Fail loudly so the bug surfaces in tests / code review.
client := NewClient("http://unreachable.invalid", "test_api_key")
_, _, err := client.CreatePaymentSimple(
context.Background(),
"", // empty key
100, "EUR", "", "", nil,
)
if err == nil {
t.Fatal("expected error for empty idempotency key")
}
}
func TestClient_CreatePayment_HTTPError(t *testing.T) {
@ -52,12 +78,50 @@ func TestClient_CreatePayment_HTTPError(t *testing.T) {
defer server.Close()
client := NewClient(server.URL, "test_api_key")
_, _, err := client.CreatePaymentSimple(context.Background(), 100, "EUR", "", "", nil)
_, _, err := client.CreatePaymentSimple(context.Background(), "order-err", 100, "EUR", "", "", nil)
if err == nil {
t.Fatal("expected error for 400 response")
}
}
func TestClient_CreateRefund_SendsIdempotencyKey(t *testing.T) {
const expectedIdempKey = "refund-abc-123"
var gotIdempKey string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/refunds" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
gotIdempKey = r.Header.Get("Idempotency-Key")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"refund_id": "ref_test_456",
"payment_id": "pay_test_123",
"status": "pending",
})
}))
defer server.Close()
client := NewClient(server.URL, "test_api_key")
resp, err := client.CreateRefund(context.Background(), expectedIdempKey, "pay_test_123", nil, "customer request")
if err != nil {
t.Fatalf("CreateRefund: %v", err)
}
if resp.RefundID != "ref_test_456" {
t.Errorf("refund_id = %q, want ref_test_456", resp.RefundID)
}
if gotIdempKey != expectedIdempKey {
t.Errorf("Idempotency-Key header = %q, want %q", gotIdempKey, expectedIdempKey)
}
}
func TestClient_CreateRefund_RejectsEmptyIdempotencyKey(t *testing.T) {
client := NewClient("http://unreachable.invalid", "test_api_key")
_, err := client.CreateRefund(context.Background(), "", "pay_123", nil, "no key")
if err == nil {
t.Fatal("expected error for empty idempotency key on CreateRefund")
}
}
func TestClient_GetPaymentStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/payments/pay_123" {

View file

@ -20,8 +20,8 @@ func NewProvider(client *Client) *Provider {
}
// CreatePayment creates a payment in Hyperswitch.
func (p *Provider) CreatePayment(ctx context.Context, amount int64, currency, orderID, returnURL string, metadata map[string]string) (paymentID, clientSecret string, err error) {
return p.client.CreatePaymentSimple(ctx, amount, currency, orderID, returnURL, metadata)
func (p *Provider) CreatePayment(ctx context.Context, idempotencyKey string, amount int64, currency, orderID, returnURL string, metadata map[string]string) (paymentID, clientSecret string, err error) {
return p.client.CreatePaymentSimple(ctx, idempotencyKey, amount, currency, orderID, returnURL, metadata)
}
// GetPayment retrieves payment status from Hyperswitch.
@ -29,22 +29,19 @@ func (p *Provider) GetPayment(ctx context.Context, paymentID string) (string, er
return p.client.GetPaymentStatus(ctx, paymentID)
}
// Refund creates a refund in Hyperswitch (v0.403 R2, kept for backward
// compatibility with any call-site that only cared about the error).
func (p *Provider) Refund(ctx context.Context, paymentID string, amount *int64, reason string) error {
_, err := p.client.CreateRefund(ctx, paymentID, amount, reason)
return err
}
// CreateRefund creates a refund in Hyperswitch and returns the PSP refund
// id and synchronous status (v1.0.6). The marketplace service persists the
// refund_id as the idempotency key for the webhook handler — every later
// refund.* notification can be correlated back to the pending Refund row
// via `hyperswitch_refund_id`.
// id and synchronous status (v1.0.6, v1.0.7 item D). The marketplace
// service persists the refund_id as the idempotency key for the webhook
// handler — every later refund.* notification can be correlated back to
// the pending Refund row via `hyperswitch_refund_id`.
//
// idempotencyKey (v1.0.7 item D): passed through to the
// `Idempotency-Key` HTTP header. Marketplace passes the pending Refund
// row's UUID — stable across HTTP retries of the same logical call.
//
// Matches marketplace.refundProvider interface.
func (p *Provider) CreateRefund(ctx context.Context, paymentID string, amount *int64, reason string) (string, string, error) {
resp, err := p.client.CreateRefund(ctx, paymentID, amount, reason)
func (p *Provider) CreateRefund(ctx context.Context, idempotencyKey, paymentID string, amount *int64, reason string) (string, string, error) {
resp, err := p.client.CreateRefund(ctx, idempotencyKey, paymentID, amount, reason)
if err != nil {
return "", "", err
}

View file

@ -367,7 +367,7 @@ type mockPaymentProvider struct {
clientSecret string
}
func (m *mockPaymentProvider) CreatePayment(_ context.Context, _ int64, _, _, _ string, _ map[string]string) (string, string, error) {
func (m *mockPaymentProvider) CreatePayment(_ context.Context, _ string, _ int64, _, _, _ string, _ map[string]string) (string, string, error) {
return m.paymentID, m.clientSecret, nil
}
func (m *mockPaymentProvider) GetPayment(_ context.Context, _ string) (string, error) {

View file

@ -37,7 +37,7 @@ type mockPaymentProvider struct {
clientSecret string
}
func (m *mockPaymentProvider) CreatePayment(_ context.Context, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) {
func (m *mockPaymentProvider) CreatePayment(_ context.Context, _ string, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) {
return m.paymentID, m.clientSecret, nil
}

View file

@ -35,7 +35,7 @@ type mockRefundPaymentProvider struct {
refundErr error
}
func (m *mockRefundPaymentProvider) CreatePayment(_ context.Context, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) {
func (m *mockRefundPaymentProvider) CreatePayment(_ context.Context, _ string, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) {
return "pay_refund_mock", "secret", nil
}
@ -43,8 +43,11 @@ func (m *mockRefundPaymentProvider) GetPayment(_ context.Context, _ string) (str
return "succeeded", nil
}
func (m *mockRefundPaymentProvider) Refund(_ context.Context, _ string, _ *int64, _ string) error {
return m.refundErr
func (m *mockRefundPaymentProvider) CreateRefund(_ context.Context, _ string, _ string, _ *int64, _ string) (string, string, error) {
if m.refundErr != nil {
return "", "", m.refundErr
}
return "ref_mock", "pending", nil
}
func setupRefundFlowRouter(t *testing.T, db *gorm.DB, marketService *marketplace.Service) *gin.Engine {