feat(backend,marketplace): refund reverse-charge with idempotent webhook
Fourth item of the v1.0.6 backlog, and the structuring one — the pre-
v1.0.6 RefundOrder wrote `status='refunded'` to the DB and called
Hyperswitch synchronously in the same transaction, treating the API
ack as terminal confirmation. In reality Hyperswitch returns `pending`
and only finalizes via webhook. Customers could see "refunded" in the
UI while their bank was still uncredited, and the seller balance
stayed credited even on successful refunds.
v1.0.6 flow
Phase 1 — open a pending refund (short row-locked transaction):
* validate permissions + 14-day window + double-submit guard
* persist Refund{status=pending}
* flip order to `refund_pending` (not `refunded` — that's the
webhook's job)
Phase 2 — call PSP outside the transaction:
* Provider.CreateRefund returns (refund_id, status, err). The
refund_id is the unique idempotency key for the webhook.
* on PSP error: mark Refund{status=failed}, roll order back to
`completed` so the buyer can retry.
* on success: persist hyperswitch_refund_id, stay in `pending`
even if the sync status is "succeeded". The webhook is the only
authoritative signal. (Per customer guidance: "ne jamais flipper
à succeeded sur la réponse synchrone du POST".)
Phase 3 — webhook drives terminal state:
* ProcessRefundWebhook looks up by hyperswitch_refund_id (UNIQUE
constraint in the new `refunds` table guarantees idempotency).
* terminal-state short-circuit: IsTerminal() returns 200 without
mutating anything, so a Hyperswitch retry storm is safe.
* on refund.succeeded: flip refund + order to succeeded/refunded,
revoke licenses, debit seller balance, mark every SellerTransfer
for the order as `reversed`. All within a row-locked tx.
* on refund.failed: flip refund to failed, order back to
`completed`.
Seller-side reconciliation
* SellerBalance.DebitSellerBalance was using Postgres-only GREATEST,
which silently failed on SQLite tests. Ported to a portable
CASE WHEN that clamps at zero in both DBs.
* SellerTransfer.Status = "reversed" captures the refund event in
the ledger. The actual Stripe Connect Transfers:reversal call is
flagged TODO(v1.0.7) — requires wiring through TransferService
with connected-account context that the current transfer worker
doesn't expose. The internal balance is corrected here so the
buyer and seller views match as soon as the PSP confirms; the
missing piece is purely the money-movement round-trip at Stripe.
Webhook routing
* HyperswitchWebhookPayload extended with event_type + refund_id +
error_message, with flat and nested (object.*) shapes supported
(same tolerance as the existing payment fields).
* New IsRefundEvent() discriminator: matches any event_type
containing "refund" (case-insensitive) or presence of refund_id.
routes_webhooks.go peeks the payload once and dispatches to
ProcessRefundWebhook or ProcessPaymentWebhook.
* No signature-verification changes — the same HMAC-SHA512 check
protects both paths.
Handler response
* POST /marketplace/orders/:id/refund now returns
`{ refund: { id, status: "pending" }, message }` so the UI can
surface the in-flight state. A new ErrRefundAlreadyRequested maps
to 400 with a "already in progress" message instead of silently
creating a duplicate row (the double-submit guard checks order
status = `refund_pending` *before* the existing-row check so the
error is explicit).
Schema
* Migration 978_refunds_table.sql adds the `refunds` table with
UNIQUE(hyperswitch_refund_id). The uniqueness constraint is the
load-bearing idempotency guarantee — a duplicate PSP notification
lands on the same DB row, and the webhook handler's
FOR UPDATE + IsTerminal() check turns it into a no-op.
* hyperswitch_refund_id is nullable (NULL between Phase 1 and
Phase 2) so the UNIQUE index ignores rows that haven't been
assigned a PSP id yet.
Partial refunds
* The Provider.CreateRefund signature carries `amount *int64`
already (nil = full), but the service call-site passes nil. Full
refunds only for v1.0.6 — partial-refund UX needs a product
decision and is deferred to v1.0.7. Flagged in the ErrRefund*
section.
Tests (15 cases, all sqlite-in-memory + httptest-style mock provider)
* RefundOrder phase 1
- OpensPendingRefund: pending state, refund_id captured, order
→ refund_pending, licenses untouched
- PSPErrorRollsBack: failed state, order reverts to completed
- DoubleRequestRejected: second call returns
ErrRefundAlreadyRequested, not a generic ErrOrderNotRefundable
- NotCompleted / NoPaymentID / Forbidden / SellerCanRefund
- ExpiredRefundWindow / FallbackExpiredNoDeadline
* ProcessRefundWebhook
- SucceededFinalizesState: refund + order + licenses + seller
balance + seller transfer all reconciled in one tx
- FailedRollsOrderBack: order returns to completed for retry
- IsRefundEventIdempotentOnReplay: second webhook asserts
succeeded_at timestamp is *unchanged*, proving the second
invocation bailed out on IsTerminal (not re-ran)
- UnknownRefundIDReturnsOK: never-issued refund_id → 200 silent
(avoids a Hyperswitch retry storm on stale events)
- MissingRefundID: explicit 400 error
- NonTerminalStatusIgnored: pending/processing leave the row
alone
* HyperswitchWebhookPayload.IsRefundEvent: 6 dispatcher cases
(flat event_type, mixed case, payment event, refund_id alone,
empty, nested object.refund_id)
Backward compat
* hyperswitch.Provider still exposes the old Refund(ctx,...) error
method for any call-site that only cared about success/failure.
* Old mockRefundPaymentProvider replaced; external mocks need to
add CreateRefund — the interface is now (refundID, status, err).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
698859cc52
commit
92cf6d6f76
8 changed files with 792 additions and 203 deletions
|
|
@ -2,6 +2,7 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -73,8 +74,23 @@ func (r *APIRouter) hyperswitchWebhookHandler() gin.HandlerFunc {
|
|||
response.Unauthorized(c, "Invalid webhook signature")
|
||||
return
|
||||
}
|
||||
if err := marketService.ProcessPaymentWebhook(c.Request.Context(), body); err != nil {
|
||||
r.logger.Error("Hyperswitch webhook: processing failed", zap.Error(err))
|
||||
// v1.0.6: dispatch refund events to ProcessRefundWebhook. Payment
|
||||
// events keep flowing through ProcessPaymentWebhook unchanged.
|
||||
var peek marketplace.HyperswitchWebhookPayload
|
||||
if err := json.Unmarshal(body, &peek); err != nil {
|
||||
r.logger.Warn("Hyperswitch webhook: payload not JSON — dispatching as payment",
|
||||
zap.Error(err))
|
||||
}
|
||||
var procErr error
|
||||
if peek.IsRefundEvent() {
|
||||
procErr = marketService.ProcessRefundWebhook(c.Request.Context(), body)
|
||||
} else {
|
||||
procErr = marketService.ProcessPaymentWebhook(c.Request.Context(), body)
|
||||
}
|
||||
if procErr != nil {
|
||||
r.logger.Error("Hyperswitch webhook: processing failed",
|
||||
zap.Bool("is_refund_event", peek.IsRefundEvent()),
|
||||
zap.Error(procErr))
|
||||
response.InternalServerError(c, "Webhook processing failed")
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -253,3 +253,54 @@ func (st *SellerTransfer) BeforeCreate(tx *gorm.DB) (err error) {
|
|||
}
|
||||
return
|
||||
}
|
||||
|
||||
// RefundStatus represents the lifecycle state of a refund. "pending" when
|
||||
// we've POSTed to the PSP but haven't received a terminal webhook yet;
|
||||
// "succeeded" / "failed" are terminal (set by the webhook handler under a
|
||||
// row-lock so duplicate notifications are idempotent no-ops).
|
||||
type RefundStatus string
|
||||
|
||||
const (
|
||||
RefundStatusPending RefundStatus = "pending"
|
||||
RefundStatusSucceeded RefundStatus = "succeeded"
|
||||
RefundStatusFailed RefundStatus = "failed"
|
||||
)
|
||||
|
||||
// Refund tracks a PSP refund attempt tied to an order. v1.0.6 — the field
|
||||
// that makes the webhook idempotent is `hyperswitch_refund_id`, which the
|
||||
// migration marks UNIQUE (nullable until the PSP assigns one in its
|
||||
// response to POST /refunds).
|
||||
type Refund struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
OrderID uuid.UUID `gorm:"type:uuid;not null;index" json:"order_id"`
|
||||
InitiatorID uuid.UUID `gorm:"type:uuid;not null" json:"initiator_id"`
|
||||
HyperswitchPaymentID string `gorm:"size:255;not null;index" json:"hyperswitch_payment_id"`
|
||||
HyperswitchRefundID string `gorm:"size:255;uniqueIndex" json:"hyperswitch_refund_id,omitempty"`
|
||||
AmountCents int64 `gorm:"not null" json:"amount_cents"`
|
||||
Currency string `gorm:"size:3;default:'EUR'" json:"currency"`
|
||||
Reason string `gorm:"type:text" json:"reason"`
|
||||
Status RefundStatus `gorm:"size:32;default:'pending';index" json:"status"`
|
||||
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
|
||||
SucceededAt *time.Time `json:"succeeded_at,omitempty"`
|
||||
FailedAt *time.Time `json:"failed_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Refund) TableName() string { return "refunds" }
|
||||
|
||||
func (r *Refund) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.ID == uuid.Nil {
|
||||
r.ID = uuid.New()
|
||||
}
|
||||
if r.Status == "" {
|
||||
r.Status = RefundStatusPending
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsTerminal returns true when the refund is in a non-pending state and
|
||||
// should not be mutated further (guards against duplicate webhooks).
|
||||
func (r *Refund) IsTerminal() bool {
|
||||
return r.Status == RefundStatusSucceeded || r.Status == RefundStatusFailed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,10 @@ func (s *Service) CreditSellerBalance(ctx context.Context, tx *gorm.DB, sellerID
|
|||
}).Error
|
||||
}
|
||||
|
||||
// DebitSellerBalance deducts from a seller's balance on refund
|
||||
// DebitSellerBalance deducts from a seller's balance on refund.
|
||||
// v1.0.6: GREATEST() is Postgres-only — switched to a portable CASE WHEN
|
||||
// so SQLite-backed tests (seller refund reversal path) can exercise the
|
||||
// clamp-at-zero semantic without a real Postgres instance.
|
||||
func (s *Service) DebitSellerBalance(ctx context.Context, tx *gorm.DB, sellerID uuid.UUID, amountCents int64, currency string) error {
|
||||
if currency == "" {
|
||||
currency = "EUR"
|
||||
|
|
@ -129,8 +132,8 @@ func (s *Service) DebitSellerBalance(ctx context.Context, tx *gorm.DB, sellerID
|
|||
return tx.WithContext(ctx).Model(&SellerBalance{}).
|
||||
Where("seller_id = ? AND currency = ?", sellerID, currency).
|
||||
Updates(map[string]interface{}{
|
||||
"available_cents": gorm.Expr("GREATEST(available_cents - ?, 0)", amountCents),
|
||||
"total_earned_cents": gorm.Expr("GREATEST(total_earned_cents - ?, 0)", amountCents),
|
||||
"available_cents": gorm.Expr("CASE WHEN available_cents < ? THEN 0 ELSE available_cents - ? END", amountCents, amountCents),
|
||||
"total_earned_cents": gorm.Expr("CASE WHEN total_earned_cents < ? THEN 0 ELSE total_earned_cents - ? END", amountCents, amountCents),
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ package marketplace
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -25,13 +27,21 @@ func setupRefundTestDB(t *testing.T) *gorm.DB {
|
|||
&Order{},
|
||||
&OrderItem{},
|
||||
&License{},
|
||||
&Refund{},
|
||||
&SellerBalance{},
|
||||
&SellerTransfer{},
|
||||
&models.Track{},
|
||||
))
|
||||
return db
|
||||
}
|
||||
|
||||
// mockRefundPaymentProvider implements both PaymentProvider and
|
||||
// refundProvider (v1.0.6: CreateRefund returns refund_id + status).
|
||||
type mockRefundPaymentProvider struct {
|
||||
refundErr error
|
||||
refundErr error
|
||||
refundIDCount atomic.Int32
|
||||
lastAmount *int64
|
||||
lastReason string
|
||||
}
|
||||
|
||||
func (m *mockRefundPaymentProvider) CreatePayment(_ context.Context, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) {
|
||||
|
|
@ -42,15 +52,40 @@ 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, amount *int64, reason string) (string, string, error) {
|
||||
if m.refundErr != nil {
|
||||
return "", "", m.refundErr
|
||||
}
|
||||
m.lastAmount = amount
|
||||
m.lastReason = reason
|
||||
n := m.refundIDCount.Add(1)
|
||||
// Return a deterministic refund id so tests can assert on it.
|
||||
return "ref_mock_" + formatN(n), "pending", nil
|
||||
}
|
||||
|
||||
func TestRefundOrder_Success(t *testing.T) {
|
||||
func formatN(n int32) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
// Keep it simple; tests only need stable uniqueness per-provider instance.
|
||||
return uuid.NewSHA1(uuid.Nil, []byte{byte(n)}).String()[:8]
|
||||
}
|
||||
|
||||
type refundTestFixture struct {
|
||||
db *gorm.DB
|
||||
svc *Service
|
||||
provider *mockRefundPaymentProvider
|
||||
buyerID uuid.UUID
|
||||
sellerID uuid.UUID
|
||||
orderID uuid.UUID
|
||||
productID uuid.UUID
|
||||
trackID uuid.UUID
|
||||
}
|
||||
|
||||
func newRefundTestFixture(t *testing.T) *refundTestFixture {
|
||||
db := setupRefundTestDB(t)
|
||||
logger := zap.NewNop()
|
||||
mock := &mockRefundPaymentProvider{}
|
||||
svc := NewService(db, logger, nil, WithPaymentProvider(mock))
|
||||
svc := NewService(db, zap.NewNop(), nil, WithPaymentProvider(mock))
|
||||
|
||||
buyerID := uuid.New()
|
||||
sellerID := uuid.New()
|
||||
|
|
@ -58,8 +93,8 @@ func TestRefundOrder_Success(t *testing.T) {
|
|||
productID := uuid.New()
|
||||
orderID := uuid.New()
|
||||
|
||||
require.NoError(t, db.Create(&models.User{ID: buyerID}).Error)
|
||||
require.NoError(t, db.Create(&models.User{ID: sellerID}).Error)
|
||||
require.NoError(t, db.Create(&models.User{ID: buyerID, Username: "buyer", Email: "b@e"}).Error)
|
||||
require.NoError(t, db.Create(&models.User{ID: sellerID, Username: "seller", Email: "s@e"}).Error)
|
||||
require.NoError(t, db.Create(&models.Track{ID: trackID, UserID: sellerID, FilePath: "/t.mp3"}).Error)
|
||||
require.NoError(t, db.Create(&Product{
|
||||
ID: productID,
|
||||
|
|
@ -86,233 +121,321 @@ func TestRefundOrder_Success(t *testing.T) {
|
|||
OrderID: orderID,
|
||||
Type: LicenseBasic,
|
||||
}).Error)
|
||||
return &refundTestFixture{
|
||||
db: db,
|
||||
svc: svc,
|
||||
provider: mock,
|
||||
buyerID: buyerID,
|
||||
sellerID: sellerID,
|
||||
orderID: orderID,
|
||||
productID: productID,
|
||||
trackID: trackID,
|
||||
}
|
||||
}
|
||||
|
||||
err := svc.RefundOrder(context.Background(), orderID, buyerID, "Customer request")
|
||||
// ---------------------------------------------------------------------------
|
||||
// RefundOrder — phase 1 (open pending refund + call PSP + capture refund_id)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRefundOrder_OpensPendingRefund(t *testing.T) {
|
||||
f := newRefundTestFixture(t)
|
||||
|
||||
refund, err := f.svc.RefundOrder(context.Background(), f.orderID, f.buyerID, "Customer request")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, refund)
|
||||
|
||||
var updated Order
|
||||
require.NoError(t, db.First(&updated, orderID).Error)
|
||||
assert.Equal(t, "refunded", updated.Status)
|
||||
assert.Equal(t, RefundStatusPending, refund.Status,
|
||||
"refund stays pending even after PSP sync ack — terminal state is set by the webhook")
|
||||
assert.NotEmpty(t, refund.HyperswitchRefundID, "PSP-assigned refund_id must be persisted")
|
||||
assert.Equal(t, int64(999), refund.AmountCents)
|
||||
assert.Equal(t, "Customer request", refund.Reason)
|
||||
assert.Nil(t, f.provider.lastAmount, "full refund must send nil amount to the PSP")
|
||||
|
||||
// Order is marked `refund_pending` — not `refunded`. Licenses are NOT yet
|
||||
// revoked: that's the webhook's job.
|
||||
var order Order
|
||||
require.NoError(t, f.db.First(&order, f.orderID).Error)
|
||||
assert.Equal(t, "refund_pending", order.Status)
|
||||
|
||||
var licenses []License
|
||||
require.NoError(t, db.Where("order_id = ?", orderID).Find(&licenses).Error)
|
||||
require.NoError(t, f.db.Where("order_id = ?", f.orderID).Find(&licenses).Error)
|
||||
require.Len(t, licenses, 1)
|
||||
assert.NotNil(t, licenses[0].RevokedAt)
|
||||
assert.Nil(t, licenses[0].RevokedAt, "licenses must not be revoked until refund_succeeded webhook")
|
||||
|
||||
// The refund row is persisted and queryable.
|
||||
var persisted Refund
|
||||
require.NoError(t, f.db.First(&persisted, refund.ID).Error)
|
||||
assert.Equal(t, refund.HyperswitchRefundID, persisted.HyperswitchRefundID)
|
||||
}
|
||||
|
||||
func TestRefundOrder_PSPErrorRollsBack(t *testing.T) {
|
||||
f := newRefundTestFixture(t)
|
||||
f.provider.refundErr = errors.New("PSP rejected")
|
||||
|
||||
_, err := f.svc.RefundOrder(context.Background(), f.orderID, f.buyerID, "reason")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hyperswitch refund")
|
||||
|
||||
var order Order
|
||||
require.NoError(t, f.db.First(&order, f.orderID).Error)
|
||||
assert.Equal(t, "completed", order.Status, "order must revert to completed on PSP error")
|
||||
|
||||
var refund Refund
|
||||
require.NoError(t, f.db.Where("order_id = ?", f.orderID).First(&refund).Error)
|
||||
assert.Equal(t, RefundStatusFailed, refund.Status)
|
||||
assert.Equal(t, "PSP rejected", refund.ErrorMessage)
|
||||
require.NotNil(t, refund.FailedAt)
|
||||
}
|
||||
|
||||
func TestRefundOrder_DoubleRequestRejected(t *testing.T) {
|
||||
f := newRefundTestFixture(t)
|
||||
|
||||
_, err := f.svc.RefundOrder(context.Background(), f.orderID, f.buyerID, "first")
|
||||
require.NoError(t, err)
|
||||
|
||||
// A second request while the first is still pending must be rejected.
|
||||
_, err = f.svc.RefundOrder(context.Background(), f.orderID, f.buyerID, "second")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrRefundAlreadyRequested)
|
||||
}
|
||||
|
||||
func TestRefundOrder_NotCompleted(t *testing.T) {
|
||||
db := setupRefundTestDB(t)
|
||||
logger := zap.NewNop()
|
||||
mock := &mockRefundPaymentProvider{}
|
||||
svc := NewService(db, logger, nil, WithPaymentProvider(mock))
|
||||
|
||||
svc := NewService(db, zap.NewNop(), nil, WithPaymentProvider(&mockRefundPaymentProvider{}))
|
||||
orderID := uuid.New()
|
||||
buyerID := uuid.New()
|
||||
require.NoError(t, db.Create(&models.User{ID: buyerID}).Error)
|
||||
require.NoError(t, db.Create(&Order{
|
||||
ID: orderID,
|
||||
BuyerID: buyerID,
|
||||
TotalAmount: 9.99,
|
||||
Currency: "EUR",
|
||||
Status: "pending",
|
||||
HyperswitchPaymentID: "pay_123",
|
||||
ID: orderID, BuyerID: buyerID, TotalAmount: 9.99, Currency: "EUR",
|
||||
Status: "pending", HyperswitchPaymentID: "pay_123",
|
||||
}).Error)
|
||||
|
||||
err := svc.RefundOrder(context.Background(), orderID, buyerID, "reason")
|
||||
require.Error(t, err)
|
||||
_, err := svc.RefundOrder(context.Background(), orderID, buyerID, "reason")
|
||||
assert.ErrorIs(t, err, ErrOrderNotRefundable)
|
||||
}
|
||||
|
||||
func TestRefundOrder_NoPaymentID(t *testing.T) {
|
||||
db := setupRefundTestDB(t)
|
||||
logger := zap.NewNop()
|
||||
mock := &mockRefundPaymentProvider{}
|
||||
svc := NewService(db, logger, nil, WithPaymentProvider(mock))
|
||||
|
||||
svc := NewService(db, zap.NewNop(), nil, WithPaymentProvider(&mockRefundPaymentProvider{}))
|
||||
orderID := uuid.New()
|
||||
buyerID := uuid.New()
|
||||
require.NoError(t, db.Create(&models.User{ID: buyerID}).Error)
|
||||
require.NoError(t, db.Create(&Order{
|
||||
ID: orderID,
|
||||
BuyerID: buyerID,
|
||||
TotalAmount: 9.99,
|
||||
Currency: "EUR",
|
||||
Status: "completed",
|
||||
HyperswitchPaymentID: "",
|
||||
ID: orderID, BuyerID: buyerID, TotalAmount: 9.99, Currency: "EUR",
|
||||
Status: "completed", HyperswitchPaymentID: "",
|
||||
}).Error)
|
||||
|
||||
err := svc.RefundOrder(context.Background(), orderID, buyerID, "reason")
|
||||
require.Error(t, err)
|
||||
_, err := svc.RefundOrder(context.Background(), orderID, buyerID, "reason")
|
||||
assert.ErrorIs(t, err, ErrOrderNotRefundable)
|
||||
}
|
||||
|
||||
func TestRefundOrder_Forbidden(t *testing.T) {
|
||||
db := setupRefundTestDB(t)
|
||||
logger := zap.NewNop()
|
||||
mock := &mockRefundPaymentProvider{}
|
||||
svc := NewService(db, logger, nil, WithPaymentProvider(mock))
|
||||
f := newRefundTestFixture(t)
|
||||
outsider := uuid.New()
|
||||
require.NoError(t, f.db.Create(&models.User{ID: outsider, Username: "o", Email: "o@e"}).Error)
|
||||
|
||||
buyerID := uuid.New()
|
||||
sellerID := uuid.New()
|
||||
otherID := uuid.New()
|
||||
trackID := uuid.New()
|
||||
productID := uuid.New()
|
||||
orderID := uuid.New()
|
||||
|
||||
require.NoError(t, db.Create(&models.User{ID: buyerID}).Error)
|
||||
require.NoError(t, db.Create(&models.User{ID: sellerID}).Error)
|
||||
require.NoError(t, db.Create(&models.User{ID: otherID}).Error)
|
||||
require.NoError(t, db.Create(&models.Track{ID: trackID, UserID: sellerID, FilePath: "/t.mp3"}).Error)
|
||||
require.NoError(t, db.Create(&Product{
|
||||
ID: productID,
|
||||
SellerID: sellerID,
|
||||
Title: "P",
|
||||
Price: 9.99,
|
||||
ProductType: "track",
|
||||
TrackID: &trackID,
|
||||
Status: ProductStatusActive,
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&Order{
|
||||
ID: orderID,
|
||||
BuyerID: buyerID,
|
||||
TotalAmount: 9.99,
|
||||
Currency: "EUR",
|
||||
Status: "completed",
|
||||
HyperswitchPaymentID: "pay_123",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&OrderItem{ID: uuid.New(), OrderID: orderID, ProductID: productID, Price: 9.99}).Error)
|
||||
|
||||
err := svc.RefundOrder(context.Background(), orderID, otherID, "reason")
|
||||
require.Error(t, err)
|
||||
_, err := f.svc.RefundOrder(context.Background(), f.orderID, outsider, "reason")
|
||||
assert.ErrorIs(t, err, ErrRefundForbidden)
|
||||
}
|
||||
|
||||
func TestRefundOrder_SellerCanRefund(t *testing.T) {
|
||||
db := setupRefundTestDB(t)
|
||||
logger := zap.NewNop()
|
||||
mock := &mockRefundPaymentProvider{}
|
||||
svc := NewService(db, logger, nil, WithPaymentProvider(mock))
|
||||
f := newRefundTestFixture(t)
|
||||
|
||||
buyerID := uuid.New()
|
||||
sellerID := uuid.New()
|
||||
trackID := uuid.New()
|
||||
productID := uuid.New()
|
||||
orderID := uuid.New()
|
||||
|
||||
require.NoError(t, db.Create(&models.User{ID: buyerID}).Error)
|
||||
require.NoError(t, db.Create(&models.User{ID: sellerID}).Error)
|
||||
require.NoError(t, db.Create(&models.Track{ID: trackID, UserID: sellerID, FilePath: "/t.mp3"}).Error)
|
||||
require.NoError(t, db.Create(&Product{
|
||||
ID: productID,
|
||||
SellerID: sellerID,
|
||||
Title: "P",
|
||||
Price: 9.99,
|
||||
ProductType: "track",
|
||||
TrackID: &trackID,
|
||||
Status: ProductStatusActive,
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&Order{
|
||||
ID: orderID,
|
||||
BuyerID: buyerID,
|
||||
TotalAmount: 9.99,
|
||||
Currency: "EUR",
|
||||
Status: "completed",
|
||||
HyperswitchPaymentID: "pay_123",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&OrderItem{ID: uuid.New(), OrderID: orderID, ProductID: productID, Price: 9.99}).Error)
|
||||
require.NoError(t, db.Create(&License{
|
||||
BuyerID: buyerID,
|
||||
TrackID: trackID,
|
||||
ProductID: productID,
|
||||
OrderID: orderID,
|
||||
Type: LicenseBasic,
|
||||
}).Error)
|
||||
|
||||
err := svc.RefundOrder(context.Background(), orderID, sellerID, "Seller initiated")
|
||||
refund, err := f.svc.RefundOrder(context.Background(), f.orderID, f.sellerID, "Seller initiated")
|
||||
require.NoError(t, err)
|
||||
|
||||
var updated Order
|
||||
require.NoError(t, db.First(&updated, orderID).Error)
|
||||
assert.Equal(t, "refunded", updated.Status)
|
||||
assert.Equal(t, RefundStatusPending, refund.Status)
|
||||
assert.Equal(t, f.sellerID, refund.InitiatorID)
|
||||
}
|
||||
|
||||
// v0.12.0: Test 14-day refund window enforcement
|
||||
func TestRefundOrder_ExpiredRefundWindow(t *testing.T) {
|
||||
db := setupRefundTestDB(t)
|
||||
logger := zap.NewNop()
|
||||
mock := &mockRefundPaymentProvider{}
|
||||
svc := NewService(db, logger, nil, WithPaymentProvider(mock))
|
||||
|
||||
svc := NewService(db, zap.NewNop(), nil, WithPaymentProvider(&mockRefundPaymentProvider{}))
|
||||
orderID := uuid.New()
|
||||
buyerID := uuid.New()
|
||||
require.NoError(t, db.Create(&models.User{ID: buyerID}).Error)
|
||||
|
||||
// Set refund deadline to the past (expired)
|
||||
expired := time.Now().Add(-24 * time.Hour)
|
||||
require.NoError(t, db.Create(&Order{
|
||||
ID: orderID,
|
||||
BuyerID: buyerID,
|
||||
TotalAmount: 9.99,
|
||||
Currency: "EUR",
|
||||
Status: "completed",
|
||||
HyperswitchPaymentID: "pay_123",
|
||||
RefundDeadline: &expired,
|
||||
ID: orderID, BuyerID: buyerID, TotalAmount: 9.99, Currency: "EUR",
|
||||
Status: "completed", HyperswitchPaymentID: "pay_123", RefundDeadline: &expired,
|
||||
}).Error)
|
||||
|
||||
err := svc.RefundOrder(context.Background(), orderID, buyerID, "reason")
|
||||
_, err := svc.RefundOrder(context.Background(), orderID, buyerID, "reason")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrOrderNotRefundable)
|
||||
assert.Contains(t, err.Error(), "refund window expired")
|
||||
}
|
||||
|
||||
// v0.12.0: Test 14-day fallback when no deadline set
|
||||
func TestRefundOrder_FallbackExpiredNoDeadline(t *testing.T) {
|
||||
db := setupRefundTestDB(t)
|
||||
logger := zap.NewNop()
|
||||
mock := &mockRefundPaymentProvider{}
|
||||
svc := NewService(db, logger, nil, WithPaymentProvider(mock))
|
||||
|
||||
svc := NewService(db, zap.NewNop(), nil, WithPaymentProvider(&mockRefundPaymentProvider{}))
|
||||
orderID := uuid.New()
|
||||
buyerID := uuid.New()
|
||||
require.NoError(t, db.Create(&models.User{ID: buyerID}).Error)
|
||||
|
||||
// Order created 15 days ago, no deadline set
|
||||
order := &Order{
|
||||
ID: orderID,
|
||||
BuyerID: buyerID,
|
||||
TotalAmount: 9.99,
|
||||
Currency: "EUR",
|
||||
Status: "completed",
|
||||
HyperswitchPaymentID: "pay_123",
|
||||
ID: orderID, BuyerID: buyerID, TotalAmount: 9.99, Currency: "EUR",
|
||||
Status: "completed", HyperswitchPaymentID: "pay_123",
|
||||
}
|
||||
require.NoError(t, db.Create(order).Error)
|
||||
// Manually backdate the created_at
|
||||
db.Model(order).Update("created_at", time.Now().Add(-15*24*time.Hour))
|
||||
|
||||
err := svc.RefundOrder(context.Background(), orderID, buyerID, "reason")
|
||||
require.Error(t, err)
|
||||
_, err := svc.RefundOrder(context.Background(), orderID, buyerID, "reason")
|
||||
assert.ErrorIs(t, err, ErrOrderNotRefundable)
|
||||
}
|
||||
|
||||
func TestRefundOrder_RefundProviderError(t *testing.T) {
|
||||
db := setupRefundTestDB(t)
|
||||
logger := zap.NewNop()
|
||||
mock := &mockRefundPaymentProvider{refundErr: errors.New("hyperswitch error")}
|
||||
svc := NewService(db, logger, nil, WithPaymentProvider(mock))
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProcessRefundWebhook — phase 2 (terminal finalization, idempotency)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
orderID := uuid.New()
|
||||
buyerID := uuid.New()
|
||||
require.NoError(t, db.Create(&models.User{ID: buyerID}).Error)
|
||||
require.NoError(t, db.Create(&Order{
|
||||
ID: orderID,
|
||||
BuyerID: buyerID,
|
||||
TotalAmount: 9.99,
|
||||
Currency: "EUR",
|
||||
Status: "completed",
|
||||
HyperswitchPaymentID: "pay_123",
|
||||
func makeWebhook(eventType, refundID, status, errMsg string) []byte {
|
||||
payload := map[string]interface{}{
|
||||
"event_type": eventType,
|
||||
"refund_id": refundID,
|
||||
"status": status,
|
||||
"error_message": errMsg,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestProcessRefundWebhook_SucceededFinalizesState(t *testing.T) {
|
||||
f := newRefundTestFixture(t)
|
||||
|
||||
// Seed a transferred seller balance so the reversal path has something
|
||||
// to undo.
|
||||
require.NoError(t, f.db.Create(&SellerBalance{
|
||||
SellerID: f.sellerID, AvailableCents: 849, TotalEarnedCents: 849, Currency: "EUR",
|
||||
}).Error)
|
||||
require.NoError(t, f.db.Create(&SellerTransfer{
|
||||
SellerID: f.sellerID, OrderID: f.orderID,
|
||||
AmountCents: 849, PlatformFeeCents: 150, Currency: "EUR", Status: "completed",
|
||||
}).Error)
|
||||
|
||||
err := svc.RefundOrder(context.Background(), orderID, buyerID, "reason")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hyperswitch refund")
|
||||
refund, err := f.svc.RefundOrder(context.Background(), f.orderID, f.buyerID, "ok")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = f.svc.ProcessRefundWebhook(context.Background(),
|
||||
makeWebhook("refund.succeeded", refund.HyperswitchRefundID, "succeeded", ""))
|
||||
require.NoError(t, err)
|
||||
|
||||
var persisted Refund
|
||||
require.NoError(t, f.db.First(&persisted, refund.ID).Error)
|
||||
assert.Equal(t, RefundStatusSucceeded, persisted.Status)
|
||||
require.NotNil(t, persisted.SucceededAt)
|
||||
|
||||
var order Order
|
||||
require.NoError(t, f.db.First(&order, f.orderID).Error)
|
||||
assert.Equal(t, "refunded", order.Status)
|
||||
|
||||
var licenses []License
|
||||
require.NoError(t, f.db.Where("order_id = ?", f.orderID).Find(&licenses).Error)
|
||||
require.Len(t, licenses, 1)
|
||||
assert.NotNil(t, licenses[0].RevokedAt, "license revocation happens on webhook succeeded")
|
||||
|
||||
var transfer SellerTransfer
|
||||
require.NoError(t, f.db.Where("order_id = ?", f.orderID).First(&transfer).Error)
|
||||
assert.Equal(t, "reversed", transfer.Status)
|
||||
|
||||
var balance SellerBalance
|
||||
require.NoError(t, f.db.Where("seller_id = ?", f.sellerID).First(&balance).Error)
|
||||
assert.Equal(t, int64(0), balance.AvailableCents, "seller balance is debited by the refunded amount")
|
||||
}
|
||||
|
||||
func TestProcessRefundWebhook_FailedRollsOrderBack(t *testing.T) {
|
||||
f := newRefundTestFixture(t)
|
||||
refund, err := f.svc.RefundOrder(context.Background(), f.orderID, f.buyerID, "ok")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = f.svc.ProcessRefundWebhook(context.Background(),
|
||||
makeWebhook("refund.failed", refund.HyperswitchRefundID, "failed", "PSP reversed: insufficient_funds"))
|
||||
require.NoError(t, err)
|
||||
|
||||
var persisted Refund
|
||||
require.NoError(t, f.db.First(&persisted, refund.ID).Error)
|
||||
assert.Equal(t, RefundStatusFailed, persisted.Status)
|
||||
assert.Equal(t, "PSP reversed: insufficient_funds", persisted.ErrorMessage)
|
||||
|
||||
var order Order
|
||||
require.NoError(t, f.db.First(&order, f.orderID).Error)
|
||||
assert.Equal(t, "completed", order.Status, "order rolls back to completed so a retry is possible")
|
||||
}
|
||||
|
||||
func TestProcessRefundWebhook_IdempotentOnReplay(t *testing.T) {
|
||||
f := newRefundTestFixture(t)
|
||||
refund, err := f.svc.RefundOrder(context.Background(), f.orderID, f.buyerID, "ok")
|
||||
require.NoError(t, err)
|
||||
|
||||
webhook := makeWebhook("refund.succeeded", refund.HyperswitchRefundID, "succeeded", "")
|
||||
|
||||
// First delivery: mutates state.
|
||||
require.NoError(t, f.svc.ProcessRefundWebhook(context.Background(), webhook))
|
||||
var firstPass Refund
|
||||
require.NoError(t, f.db.First(&firstPass, refund.ID).Error)
|
||||
firstTimestamp := firstPass.SucceededAt
|
||||
|
||||
// Second delivery (Hyperswitch retry): must be a no-op. Crucially, the
|
||||
// succeeded_at timestamp must not change — that proves the second call
|
||||
// bailed out on IsTerminal() rather than re-running the transaction.
|
||||
require.NoError(t, f.svc.ProcessRefundWebhook(context.Background(), webhook))
|
||||
var secondPass Refund
|
||||
require.NoError(t, f.db.First(&secondPass, refund.ID).Error)
|
||||
assert.Equal(t, firstTimestamp.UnixNano(), secondPass.SucceededAt.UnixNano(),
|
||||
"duplicate webhook must not overwrite the succeeded_at timestamp")
|
||||
}
|
||||
|
||||
func TestProcessRefundWebhook_UnknownRefundIDReturnsOK(t *testing.T) {
|
||||
f := newRefundTestFixture(t)
|
||||
// A refund_id that was never issued by us must not error — otherwise
|
||||
// Hyperswitch retries forever.
|
||||
err := f.svc.ProcessRefundWebhook(context.Background(),
|
||||
makeWebhook("refund.succeeded", "ref_never_seen_this_id", "succeeded", ""))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestProcessRefundWebhook_MissingRefundID(t *testing.T) {
|
||||
f := newRefundTestFixture(t)
|
||||
err := f.svc.ProcessRefundWebhook(context.Background(),
|
||||
makeWebhook("refund.succeeded", "", "succeeded", ""))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "missing refund_id")
|
||||
}
|
||||
|
||||
func TestProcessRefundWebhook_NonTerminalStatusIgnored(t *testing.T) {
|
||||
f := newRefundTestFixture(t)
|
||||
refund, err := f.svc.RefundOrder(context.Background(), f.orderID, f.buyerID, "ok")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = f.svc.ProcessRefundWebhook(context.Background(),
|
||||
makeWebhook("refund.updated", refund.HyperswitchRefundID, "pending", ""))
|
||||
require.NoError(t, err)
|
||||
|
||||
var persisted Refund
|
||||
require.NoError(t, f.db.First(&persisted, refund.ID).Error)
|
||||
assert.Equal(t, RefundStatusPending, persisted.Status,
|
||||
"pending/processing statuses must leave the refund row untouched")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HyperswitchWebhookPayload.IsRefundEvent dispatcher logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHyperswitchWebhookPayload_IsRefundEvent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantOK bool
|
||||
}{
|
||||
{"refund event_type", `{"event_type":"refund.succeeded","refund_id":"ref_1"}`, true},
|
||||
{"refund event_type mixed case", `{"event_type":"Refund.Failed"}`, true},
|
||||
{"payment event_type", `{"event_type":"payment_intent.succeeded","payment_id":"pay_1"}`, false},
|
||||
{"refund_id alone", `{"refund_id":"ref_2","status":"succeeded"}`, true},
|
||||
{"empty", `{}`, false},
|
||||
{"nested refund_id", `{"object":{"refund_id":"ref_3"}}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var wp HyperswitchWebhookPayload
|
||||
require.NoError(t, json.Unmarshal([]byte(tc.body), &wp))
|
||||
assert.Equal(t, tc.wantOK, wp.IsRefundEvent())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,8 +94,11 @@ type MarketplaceService interface {
|
|||
// v0.403 F1: Invoices
|
||||
GenerateInvoice(ctx context.Context, orderID, buyerID uuid.UUID) ([]byte, error)
|
||||
|
||||
// v0.403 R2: Refunds
|
||||
RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUID, reason string) error
|
||||
// v0.403 R2 + v1.0.6: Refunds. RefundOrder opens a refund with the PSP
|
||||
// and returns the persisted Refund row in `pending` state. The terminal
|
||||
// transition (succeeded / failed) is driven by ProcessRefundWebhook.
|
||||
RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUID, reason string) (*Refund, error)
|
||||
ProcessRefundWebhook(ctx context.Context, payload []byte) error
|
||||
}
|
||||
|
||||
// ProductImageInput represents input for adding/updating product images
|
||||
|
|
@ -576,14 +579,22 @@ func (s *Service) ListOrders(ctx context.Context, buyerID uuid.UUID) ([]Order, e
|
|||
|
||||
// HyperswitchWebhookPayload represents the webhook payload from Hyperswitch.
|
||||
// Supports both flat { payment_id, status } and nested { object: { payment_id, status } } formats.
|
||||
// v1.0.6: extended with `event_type`, `refund_id`, and `error_message` so
|
||||
// refund.* events can be dispatched and correlated alongside the pre-
|
||||
// existing payment events.
|
||||
type HyperswitchWebhookPayload struct {
|
||||
PaymentID string `json:"payment_id"`
|
||||
Status string `json:"status"`
|
||||
Amount *int64 `json:"amount"` // SECURITY(REM-014): Amount for validation against order total
|
||||
Object *struct {
|
||||
PaymentID string `json:"payment_id"`
|
||||
Status string `json:"status"`
|
||||
Amount *int64 `json:"amount"`
|
||||
EventType string `json:"event_type"`
|
||||
PaymentID string `json:"payment_id"`
|
||||
RefundID string `json:"refund_id"`
|
||||
Status string `json:"status"`
|
||||
Amount *int64 `json:"amount"` // SECURITY(REM-014): Amount for validation against order total
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Object *struct {
|
||||
PaymentID string `json:"payment_id"`
|
||||
RefundID string `json:"refund_id"`
|
||||
Status string `json:"status"`
|
||||
Amount *int64 `json:"amount"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
} `json:"object"`
|
||||
}
|
||||
|
||||
|
|
@ -597,6 +608,16 @@ func (wp *HyperswitchWebhookPayload) getPaymentID() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (wp *HyperswitchWebhookPayload) getRefundID() string {
|
||||
if wp.RefundID != "" {
|
||||
return wp.RefundID
|
||||
}
|
||||
if wp.Object != nil && wp.Object.RefundID != "" {
|
||||
return wp.Object.RefundID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (wp *HyperswitchWebhookPayload) getStatus() string {
|
||||
if wp.Status != "" {
|
||||
return wp.Status
|
||||
|
|
@ -618,6 +639,27 @@ func (wp *HyperswitchWebhookPayload) getAmount() *int64 {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (wp *HyperswitchWebhookPayload) getErrorMessage() string {
|
||||
if wp.ErrorMessage != "" {
|
||||
return wp.ErrorMessage
|
||||
}
|
||||
if wp.Object != nil && wp.Object.ErrorMessage != "" {
|
||||
return wp.Object.ErrorMessage
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsRefundEvent returns true when the payload is a refund.* event or the
|
||||
// shape suggests a refund (refund_id present without matching payment event).
|
||||
// Used by the webhook router to dispatch to ProcessRefundWebhook vs.
|
||||
// ProcessPaymentWebhook.
|
||||
func (wp *HyperswitchWebhookPayload) IsRefundEvent() bool {
|
||||
if wp.EventType != "" && strings.Contains(strings.ToLower(wp.EventType), "refund") {
|
||||
return true
|
||||
}
|
||||
return wp.getRefundID() != ""
|
||||
}
|
||||
|
||||
// ProcessPaymentWebhook handles Hyperswitch payment webhook.
|
||||
// Updates order status and creates licenses when status is "succeeded".
|
||||
func (s *Service) ProcessPaymentWebhook(ctx context.Context, payload []byte) error {
|
||||
|
|
@ -1150,25 +1192,53 @@ func (s *Service) ListReviews(ctx context.Context, productID uuid.UUID, limit, o
|
|||
return reviews, nil
|
||||
}
|
||||
|
||||
// refundProvider is implemented by hyperswitch.Provider for refunds (v0.403 R2)
|
||||
// refundProvider is implemented by hyperswitch.Provider for refunds.
|
||||
// v1.0.6: CreateRefund returns the PSP-assigned refund_id + status so the
|
||||
// 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.
|
||||
type refundProvider interface {
|
||||
Refund(ctx context.Context, paymentID string, amount *int64, reason string) error
|
||||
CreateRefund(ctx context.Context, paymentID string, amount *int64, reason string) (refundID string, status string, err error)
|
||||
}
|
||||
|
||||
var ErrOrderNotRefundable = errors.New("order cannot be refunded")
|
||||
var ErrRefundNotAvailable = errors.New("refunds not available")
|
||||
var ErrRefundForbidden = errors.New("you are not allowed to refund this order")
|
||||
var ErrRefundAlreadyRequested = errors.New("refund already requested for this order")
|
||||
|
||||
// RefundOrder initiates a refund for an order (v0.403 R2)
|
||||
// SECURITY(REM-003): Uses transaction with SELECT FOR UPDATE to prevent double-refund race condition.
|
||||
func (s *Service) RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUID, reason string) error {
|
||||
// RefundOrder initiates a refund for an order.
|
||||
//
|
||||
// v1.0.6 flow (vs. the v0.403 implementation):
|
||||
// 1. Validate the order (permissions, status, 14-day window) under a row
|
||||
// lock to stop concurrent refund attempts.
|
||||
// 2. Persist a Refund row in `pending` and flip the order to
|
||||
// `refund_pending` so the UI can show the in-flight state.
|
||||
// 3. Call Hyperswitch outside the DB transaction — keeps the lock window
|
||||
// small, and lets us surface the PSP refund_id even when the DB
|
||||
// commit is slow.
|
||||
// 4. On PSP success, store the refund_id (unique, idempotency key for
|
||||
// the webhook). Stay in `pending` — never trust the synchronous
|
||||
// response for terminal finalization; wait for the webhook.
|
||||
// 5. On PSP failure, mark refund as `failed` and roll the order back to
|
||||
// `completed` so the user can retry.
|
||||
//
|
||||
// SECURITY(REM-003): row lock prevents double-refund racing.
|
||||
// Ref: Customer guidance "Ne jamais flipper à succeeded sur la réponse
|
||||
// synchrone du POST".
|
||||
func (s *Service) RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUID, reason string) (*Refund, error) {
|
||||
rp, ok := s.paymentProvider.(refundProvider)
|
||||
if !ok || rp == nil {
|
||||
return ErrRefundNotAvailable
|
||||
return nil, ErrRefundNotAvailable
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Lock the order row to prevent concurrent refund
|
||||
var pendingRefund Refund
|
||||
var paymentID string
|
||||
|
||||
// Phase 1: open the refund inside a short transaction, release the
|
||||
// order lock before hitting the PSP. The refund row is our idempotency
|
||||
// checkpoint — if the PSP call fails we can mark it failed without a
|
||||
// second DB round-trip.
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order Order
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Preload("Items").
|
||||
|
|
@ -1193,10 +1263,15 @@ func (s *Service) RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUI
|
|||
}
|
||||
}
|
||||
|
||||
if order.Status == "refund_pending" {
|
||||
// A prior call already opened a pending refund — surface the
|
||||
// specific error so the UI can show "in progress" instead of a
|
||||
// generic "not refundable".
|
||||
return ErrRefundAlreadyRequested
|
||||
}
|
||||
if order.Status != "completed" && order.Status != "paid" {
|
||||
return ErrOrderNotRefundable
|
||||
}
|
||||
// v0.12.0: Enforce 14-day refund window
|
||||
if order.RefundDeadline != nil && time.Now().After(*order.RefundDeadline) {
|
||||
return fmt.Errorf("%w: refund window expired (14 days)", ErrOrderNotRefundable)
|
||||
}
|
||||
|
|
@ -1207,22 +1282,278 @@ func (s *Service) RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUI
|
|||
return ErrOrderNotRefundable
|
||||
}
|
||||
|
||||
// Mark as refunded BEFORE calling external API to prevent double-refund
|
||||
if err := tx.Model(&Order{}).Where("id = ?", orderID).Updates(map[string]interface{}{
|
||||
"status": "refunded",
|
||||
}).Error; err != nil {
|
||||
// Guard against double-submit from the same user: if a pending or
|
||||
// succeeded refund already exists for this order, refuse.
|
||||
var existing Refund
|
||||
err := tx.Where("order_id = ? AND status IN ?", orderID, []RefundStatus{RefundStatusPending, RefundStatusSucceeded}).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
return ErrRefundAlreadyRequested
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Call external refund API
|
||||
if err := rp.Refund(ctx, order.HyperswitchPaymentID, nil, reason); err != nil {
|
||||
return fmt.Errorf("hyperswitch refund: %w", err)
|
||||
pendingRefund = Refund{
|
||||
OrderID: orderID,
|
||||
InitiatorID: initiatorID,
|
||||
HyperswitchPaymentID: order.HyperswitchPaymentID,
|
||||
AmountCents: int64(order.TotalAmount * 100),
|
||||
Currency: order.Currency,
|
||||
Reason: reason,
|
||||
Status: RefundStatusPending,
|
||||
}
|
||||
if err := tx.Create(&pendingRefund).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&Order{}).Where("id = ?", orderID).
|
||||
Update("status", "refund_pending").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
paymentID = order.HyperswitchPaymentID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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)
|
||||
if pspErr != nil {
|
||||
now := time.Now().UTC()
|
||||
errMsg := pspErr.Error()
|
||||
if dbErr := s.db.WithContext(ctx).Model(&Refund{}).Where("id = ?", pendingRefund.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": RefundStatusFailed,
|
||||
"error_message": errMsg,
|
||||
"failed_at": &now,
|
||||
}).Error; dbErr != nil {
|
||||
s.logger.Error("Failed to persist PSP-rejected refund",
|
||||
zap.String("refund_id", pendingRefund.ID.String()),
|
||||
zap.Error(dbErr))
|
||||
}
|
||||
// Roll order back so the UI reflects reality (still completed, not
|
||||
// mid-refund). If the revert fails we log loudly — the operator
|
||||
// needs to know the order is stuck in refund_pending.
|
||||
if dbErr := s.db.WithContext(ctx).Model(&Order{}).Where("id = ?", orderID).
|
||||
Update("status", "completed").Error; dbErr != nil {
|
||||
s.logger.Error("Failed to revert order to completed after PSP refund error",
|
||||
zap.String("order_id", orderID.String()),
|
||||
zap.Error(dbErr))
|
||||
}
|
||||
return nil, fmt.Errorf("hyperswitch refund: %w", pspErr)
|
||||
}
|
||||
|
||||
// Phase 3: capture the PSP refund_id so the webhook can correlate.
|
||||
// We intentionally stay in `pending` even if hsStatus == "succeeded"
|
||||
// — the sync ack is not authoritative, the webhook is.
|
||||
if updateErr := s.db.WithContext(ctx).Model(&Refund{}).Where("id = ?", pendingRefund.ID).
|
||||
Update("hyperswitch_refund_id", refundID).Error; updateErr != nil {
|
||||
s.logger.Error("Failed to persist hyperswitch_refund_id",
|
||||
zap.String("refund_id", pendingRefund.ID.String()),
|
||||
zap.String("hyperswitch_refund_id", refundID),
|
||||
zap.Error(updateErr))
|
||||
}
|
||||
pendingRefund.HyperswitchRefundID = refundID
|
||||
s.logger.Info("Refund opened with PSP",
|
||||
zap.String("refund_id", pendingRefund.ID.String()),
|
||||
zap.String("hyperswitch_refund_id", refundID),
|
||||
zap.String("hs_sync_status", hsStatus),
|
||||
zap.String("order_id", orderID.String()))
|
||||
return &pendingRefund, nil
|
||||
}
|
||||
|
||||
// ProcessRefundWebhook handles Hyperswitch `refund.*` webhooks.
|
||||
//
|
||||
// Idempotency invariant: the hyperswitch_refund_id column has a UNIQUE
|
||||
// constraint, so any duplicate PSP notification lands on the same DB
|
||||
// row. A row already in a terminal state returns 200 without further
|
||||
// effects, which matches Hyperswitch's retry semantics.
|
||||
//
|
||||
// On `succeeded`:
|
||||
// - flip refund status to succeeded
|
||||
// - mark order as `refunded` and revoke its licenses
|
||||
// - debit the seller balance and mark the SellerTransfer as `reversed`
|
||||
// (TODO v1.0.7: call Stripe Connect reverse-transfer API to move the
|
||||
// money back at the PSP level — internal accounting is corrected here)
|
||||
//
|
||||
// On `failed`:
|
||||
// - flip refund status to failed
|
||||
// - roll the order back to `completed` so the buyer can retry or the
|
||||
// seller can investigate
|
||||
func (s *Service) ProcessRefundWebhook(ctx context.Context, payload []byte) error {
|
||||
var wp HyperswitchWebhookPayload
|
||||
if err := json.Unmarshal(payload, &wp); err != nil {
|
||||
s.logger.Error("Invalid Hyperswitch refund webhook payload",
|
||||
zap.Error(err), zap.ByteString("payload", payload))
|
||||
return fmt.Errorf("invalid webhook payload: %w", err)
|
||||
}
|
||||
|
||||
refundID := wp.getRefundID()
|
||||
if refundID == "" {
|
||||
return fmt.Errorf("refund webhook payload missing refund_id")
|
||||
}
|
||||
status := wp.getStatus()
|
||||
|
||||
var refund Refund
|
||||
if err := s.db.WithContext(ctx).Where("hyperswitch_refund_id = ?", refundID).
|
||||
First(&refund).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Could be a replay for a refund we never opened (config mismatch,
|
||||
// stale webhook, attack). Log + return 200 — bouncing the webhook
|
||||
// would just trigger Hyperswitch retries.
|
||||
s.logger.Warn("Refund webhook: refund not found",
|
||||
zap.String("hyperswitch_refund_id", refundID))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if refund.IsTerminal() {
|
||||
s.logger.Debug("Refund webhook: already in terminal state, no-op",
|
||||
zap.String("refund_id", refund.ID.String()),
|
||||
zap.String("status", string(refund.Status)))
|
||||
return nil
|
||||
}
|
||||
|
||||
switch status {
|
||||
case "succeeded":
|
||||
return s.finalizeSuccessfulRefund(ctx, refund.ID)
|
||||
case "failed":
|
||||
return s.finalizeFailedRefund(ctx, refund.ID, wp.getErrorMessage())
|
||||
default:
|
||||
// pending, processing, cancelled — these are transient states that
|
||||
// may not land in a terminal state. Log and return 200 so the PSP
|
||||
// doesn't retry.
|
||||
s.logger.Debug("Refund webhook: non-terminal status, ignoring",
|
||||
zap.String("refund_id", refund.ID.String()),
|
||||
zap.String("status", status))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) finalizeSuccessfulRefund(ctx context.Context, refundID uuid.UUID) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Row-lock the refund: a duplicate webhook that started after we
|
||||
// released the tx-less read lands here and bails out on IsTerminal.
|
||||
var locked Refund
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
First(&locked, "id = ?", refundID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if locked.IsTerminal() {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := tx.Model(&License{}).Where("order_id = ?", orderID).Update("revoked_at", now).Error; err != nil {
|
||||
s.logger.Error("Failed to revoke licenses on refund", zap.Error(err), zap.String("order_id", orderID.String()))
|
||||
now := time.Now().UTC()
|
||||
if err := tx.Model(&Refund{}).Where("id = ?", locked.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": RefundStatusSucceeded,
|
||||
"succeeded_at": &now,
|
||||
"error_message": "",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var order Order
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
First(&order, "id = ?", locked.OrderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&Order{}).Where("id = ?", order.ID).
|
||||
Update("status", "refunded").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&License{}).Where("order_id = ?", order.ID).
|
||||
Update("revoked_at", now).Error; err != nil {
|
||||
s.logger.Error("Failed to revoke licenses on refund",
|
||||
zap.Error(err), zap.String("order_id", order.ID.String()))
|
||||
return err
|
||||
}
|
||||
|
||||
// v1.0.6: reverse the seller-side accounting so the buyer+seller
|
||||
// balance sheets stay consistent. Stripe Connect API-level reversal
|
||||
// is tracked as a follow-up — the internal balance is corrected
|
||||
// here to match reality immediately.
|
||||
s.reverseSellerAccounting(ctx, tx, order.ID)
|
||||
|
||||
s.logger.Info("Refund finalized as succeeded",
|
||||
zap.String("refund_id", locked.ID.String()),
|
||||
zap.String("order_id", order.ID.String()))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) finalizeFailedRefund(ctx context.Context, refundID uuid.UUID, pspError string) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var locked Refund
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
First(&locked, "id = ?", refundID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if locked.IsTerminal() {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := tx.Model(&Refund{}).Where("id = ?", locked.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": RefundStatusFailed,
|
||||
"failed_at": &now,
|
||||
"error_message": pspError,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Roll the order back to `completed` so a retry is possible.
|
||||
if err := tx.Model(&Order{}).Where("id = ?", locked.OrderID).
|
||||
Update("status", "completed").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Warn("Refund finalized as failed",
|
||||
zap.String("refund_id", locked.ID.String()),
|
||||
zap.String("order_id", locked.OrderID.String()),
|
||||
zap.String("psp_error", pspError))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// reverseSellerAccounting undoes the balance credit + marks every
|
||||
// SellerTransfer for the order as `reversed`. Best-effort: a balance row
|
||||
// that's already been paid out (available_cents insufficient) won't go
|
||||
// negative because DebitSellerBalance caps at zero.
|
||||
//
|
||||
// TODO(v1.0.7): call Stripe Connect Transfers:reversal endpoint via
|
||||
// TransferService so the money actually moves back out of the seller's
|
||||
// connected account. Today we only correct the in-DB accounting — a real
|
||||
// Stripe settlement would still show the seller as paid.
|
||||
func (s *Service) reverseSellerAccounting(ctx context.Context, tx *gorm.DB, orderID uuid.UUID) {
|
||||
var transfers []SellerTransfer
|
||||
if err := tx.Where("order_id = ?", orderID).Find(&transfers).Error; err != nil {
|
||||
s.logger.Error("Failed to load seller transfers for refund reversal",
|
||||
zap.String("order_id", orderID.String()), zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, t := range transfers {
|
||||
if t.Status == "reversed" {
|
||||
continue
|
||||
}
|
||||
if err := s.DebitSellerBalance(ctx, tx, t.SellerID, t.AmountCents, t.Currency); err != nil {
|
||||
s.logger.Error("Failed to debit seller balance on refund",
|
||||
zap.String("seller_id", t.SellerID.String()),
|
||||
zap.String("order_id", orderID.String()),
|
||||
zap.Int64("amount_cents", t.AmountCents),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&SellerTransfer{}).Where("id = ?", t.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": "reversed",
|
||||
"error_message": "reversed by refund",
|
||||
}).Error; err != nil {
|
||||
s.logger.Error("Failed to mark seller transfer as reversed",
|
||||
zap.String("transfer_id", t.ID.String()),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -773,7 +773,8 @@ func (h *MarketplaceHandler) RefundOrder(c *gin.Context) {
|
|||
if reason == "" {
|
||||
reason = "Requested by customer"
|
||||
}
|
||||
if err := h.service.RefundOrder(c.Request.Context(), orderID, userID, reason); err != nil {
|
||||
refund, err := h.service.RefundOrder(c.Request.Context(), orderID, userID, reason)
|
||||
if err != nil {
|
||||
if err == marketplace.ErrOrderNotFound {
|
||||
response.NotFound(c, "Order not found")
|
||||
return
|
||||
|
|
@ -790,10 +791,23 @@ func (h *MarketplaceHandler) RefundOrder(c *gin.Context) {
|
|||
response.Forbidden(c, "You are not allowed to refund this order")
|
||||
return
|
||||
}
|
||||
if err == marketplace.ErrRefundAlreadyRequested {
|
||||
response.BadRequest(c, "A refund is already in progress for this order")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "Failed to process refund")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"message": "Refund processed"})
|
||||
// v1.0.6: refund is pending until the Hyperswitch webhook confirms.
|
||||
// Surface the refund id + status so the UI can show "pending" and
|
||||
// later re-query the order.
|
||||
response.Success(c, gin.H{
|
||||
"message": "Refund requested — you'll be notified when it's finalized.",
|
||||
"refund": gin.H{
|
||||
"id": refund.ID,
|
||||
"status": refund.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetOrderInvoice returns a PDF invoice for an order (v0.403 F1)
|
||||
|
|
|
|||
|
|
@ -29,8 +29,24 @@ 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).
|
||||
// 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`.
|
||||
//
|
||||
// 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)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return resp.RefundID, resp.Status, nil
|
||||
}
|
||||
|
|
|
|||
35
veza-backend-api/migrations/978_refunds_table.sql
Normal file
35
veza-backend-api/migrations/978_refunds_table.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
-- Migration 978: Refund state machine for Hyperswitch reverse-charge (v1.0.6)
|
||||
-- Before v1.0.6, RefundOrder marked orders as `refunded` immediately after
|
||||
-- POSTing to /refunds — treating Hyperswitch's synchronous API ack as
|
||||
-- confirmation that the money had moved. In reality the PSP returns `pending`
|
||||
-- and only confirms via webhook (`refund_succeeded` / `refund_failed`).
|
||||
-- Customers could see "refunded" in the UI while their bank account still
|
||||
-- hadn't been credited.
|
||||
--
|
||||
-- This migration introduces an auditable refund row that tracks the full
|
||||
-- lifecycle: pending → (succeeded | failed). Uniqueness on
|
||||
-- hyperswitch_refund_id is the load-bearing constraint — it guarantees that
|
||||
-- the webhook handler can safely retry (idempotent 200) because a duplicate
|
||||
-- PSP notification lands on the same DB row.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.refunds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_id UUID NOT NULL REFERENCES public.orders(id) ON DELETE CASCADE,
|
||||
initiator_id UUID NOT NULL REFERENCES public.users(id) ON DELETE SET NULL,
|
||||
hyperswitch_payment_id TEXT NOT NULL,
|
||||
hyperswitch_refund_id TEXT UNIQUE,
|
||||
amount_cents BIGINT NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
succeeded_at TIMESTAMPTZ,
|
||||
failed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refunds_order_id ON public.refunds(order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refunds_status ON public.refunds(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_refunds_hyperswitch_payment_id
|
||||
ON public.refunds(hyperswitch_payment_id);
|
||||
Loading…
Reference in a new issue