Some checks failed
Veza CI / Backend (Go) (push) Failing after 0s
Veza CI / Rust (Stream Server) (push) Failing after 0s
Veza CI / Frontend (Web) (push) Failing after 0s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 0s
Veza CI / Notify on failure (push) Failing after 0s
Day-2 cut of item B: the reversal path becomes async. Pre-v1.0.7
(and v1.0.7 day 1) the refund handler flipped seller_transfers
straight from completed to reversed without ever calling Stripe —
the ledger said "reversed" while the seller's Stripe balance still
showed the original transfer as settled. The new flow:
refund.succeeded webhook
→ reverseSellerAccounting transitions row: completed → reversal_pending
→ StripeReversalWorker (every REVERSAL_CHECK_INTERVAL, default 1m)
→ calls ReverseTransfer on Stripe
→ success: row → reversed + persist stripe_reversal_id
→ 404 already-reversed (dead code until day 3): row → reversed + log
→ 404 resource_missing (dead code until day 3): row → permanently_failed
→ transient error: stay reversal_pending, bump retry_count,
exponential backoff (base * 2^retry, capped at backoffMax)
→ retries exhausted: row → permanently_failed
→ buyer-facing refund completes immediately regardless of Stripe health
State machine enforcement:
* New `SellerTransfer.TransitionStatus(tx, to, extras)` wraps every
mutation: validates against AllowedTransferTransitions, guarded
UPDATE with WHERE status=<from> (optimistic lock semantics), no
RowsAffected = stale state / concurrent winner detected.
* processSellerTransfers no longer mutates .Status in place —
terminal status is decided before struct construction, so the
row is Created with its final state.
* transfer_retry.retryOne and admin RetryTransfer route through
TransitionStatus. Legacy direct assignment removed.
* TestNoDirectTransferStatusMutation greps the package for any
`st.Status = "..."` / `t.Status = "..."` / GORM
Model(&SellerTransfer{}).Update("status"...) outside the
allowlist and fails if found. Verified by temporarily injecting
a violation during development — test caught it as expected.
Configuration (v1.0.7 item B):
* REVERSAL_WORKER_ENABLED=true (default)
* REVERSAL_MAX_RETRIES=5 (default)
* REVERSAL_CHECK_INTERVAL=1m (default)
* REVERSAL_BACKOFF_BASE=1m (default)
* REVERSAL_BACKOFF_MAX=1h (default, caps exponential growth)
* .env.template documents TRANSFER_RETRY_* and REVERSAL_* env vars
so an ops reader can grep them.
Interface change: TransferService.ReverseTransfer(ctx,
stripe_transfer_id, amount *int64, reason) (reversalID, error)
added. All four mocks extended (process_webhook, transfer_retry,
admin_transfer_handler, payment_flow integration). amount=nil means
full reversal; v1.0.7 always passes nil (partial reversal is future
scope per axis-1 P2).
Stripe 404 disambiguation (ErrTransferAlreadyReversed /
ErrTransferNotFound) is wired in the worker as dead code — the
sentinels are declared and the worker branches on them, but
StripeConnectService.ReverseTransfer doesn't yet emit them. Day 3
will parse stripe.Error.Code and populate the sentinels; no worker
change needed at that point. Keeping the handling skeleton in day 2
so the worker's branch shape doesn't change between days and the
tests can already cover all four paths against the mock.
Worker unit tests (9 cases, all green, sqlite :memory:):
* happy path: reversal_pending → reversed + stripe_reversal_id set
* already reversed (mock returns sentinel): → reversed + log
* not found (mock returns sentinel): → permanently_failed + log
* transient 503: retry_count++, next_retry_at set with backoff,
stays reversal_pending
* backoff capped at backoffMax (verified with base=1s, max=10s,
retry_count=4 → capped at 10s not 16s)
* max retries exhausted: → permanently_failed
* legacy row with empty stripe_transfer_id: → permanently_failed,
does not call Stripe
* only picks up reversal_pending (skips all other statuses)
* respects next_retry_at (future rows skipped)
Existing test updated: TestProcessRefundWebhook_SucceededFinalizesState
now asserts the row lands at reversal_pending with next_retry_at
set (worker's responsibility to drive to reversed), not reversed.
Worker wired in cmd/api/main.go alongside TransferRetryWorker,
sharing the same StripeConnectService instance. Shutdown path
registered for graceful stop.
Cut from day 2 scope (per agreed-upon discipline), landing in day 3:
* Stripe 404 disambiguation implementation (parse error.Code)
* End-to-end smoke probe (refund → reversal_pending → worker
processes → reversed) against local Postgres + mock Stripe
* Batch-size tuning / inter-batch sleep — batchLimit=20 today is
safely under Stripe's 100 req/s default rate limit; revisit if
observed load warrants
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
7.2 KiB
Go
250 lines
7.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
"veza-backend-api/internal/core/marketplace"
|
|
"veza-backend-api/internal/models"
|
|
)
|
|
|
|
func setupAdminTransferTestDB(t *testing.T) *gorm.DB {
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.AutoMigrate(
|
|
&models.User{},
|
|
&marketplace.SellerTransfer{},
|
|
))
|
|
return db
|
|
}
|
|
|
|
type mockTransferServiceAdmin struct {
|
|
err error
|
|
stripeTransferID string
|
|
}
|
|
|
|
func (m *mockTransferServiceAdmin) CreateTransfer(_ context.Context, _ uuid.UUID, _ int64, _, _ string) (string, error) {
|
|
if m.err != nil {
|
|
return "", m.err
|
|
}
|
|
id := m.stripeTransferID
|
|
if id == "" {
|
|
id = "tr_mock"
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func (m *mockTransferServiceAdmin) ReverseTransfer(_ context.Context, _ string, _ *int64, _ string) (string, error) {
|
|
return "rev_mock", nil
|
|
}
|
|
|
|
func TestGetTransfers_ReturnsAll(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
handler := NewAdminTransferHandler(db, nil, 0.10, logger)
|
|
|
|
sellerID := uuid.New()
|
|
orderID := uuid.New()
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: uuid.New(),
|
|
SellerID: sellerID,
|
|
OrderID: orderID,
|
|
AmountCents: 1000,
|
|
PlatformFeeCents: 100,
|
|
Currency: "EUR",
|
|
Status: "completed",
|
|
}).Error)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/admin/transfers", nil)
|
|
|
|
handler.GetTransfers(c)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var body map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
assert.True(t, body["success"].(bool))
|
|
data := body["data"].(map[string]interface{})
|
|
transfers := data["transfers"].([]interface{})
|
|
assert.Len(t, transfers, 1)
|
|
assert.Equal(t, float64(1), float64(data["total"].(float64)))
|
|
}
|
|
|
|
func TestGetTransfers_FilterByStatus(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
handler := NewAdminTransferHandler(db, nil, 0.10, logger)
|
|
|
|
sellerID := uuid.New()
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: uuid.New(),
|
|
SellerID: sellerID,
|
|
OrderID: uuid.New(),
|
|
AmountCents: 1000,
|
|
PlatformFeeCents: 100,
|
|
Currency: "EUR",
|
|
Status: "completed",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: uuid.New(),
|
|
SellerID: sellerID,
|
|
OrderID: uuid.New(),
|
|
AmountCents: 500,
|
|
PlatformFeeCents: 50,
|
|
Currency: "EUR",
|
|
Status: "failed",
|
|
}).Error)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/admin/transfers?status=failed", nil)
|
|
|
|
handler.GetTransfers(c)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var body map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
data := body["data"].(map[string]interface{})
|
|
transfers := data["transfers"].([]interface{})
|
|
assert.Len(t, transfers, 1)
|
|
assert.Equal(t, float64(1), float64(data["total"].(float64)))
|
|
}
|
|
|
|
func TestGetTransfers_FilterBySeller(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
handler := NewAdminTransferHandler(db, nil, 0.10, logger)
|
|
|
|
seller1 := uuid.New()
|
|
seller2 := uuid.New()
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: uuid.New(),
|
|
SellerID: seller1,
|
|
OrderID: uuid.New(),
|
|
AmountCents: 1000,
|
|
PlatformFeeCents: 100,
|
|
Currency: "EUR",
|
|
Status: "completed",
|
|
}).Error)
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: uuid.New(),
|
|
SellerID: seller2,
|
|
OrderID: uuid.New(),
|
|
AmountCents: 500,
|
|
PlatformFeeCents: 50,
|
|
Currency: "EUR",
|
|
Status: "completed",
|
|
}).Error)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/admin/transfers?seller_id="+seller1.String(), nil)
|
|
|
|
handler.GetTransfers(c)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var body map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
data := body["data"].(map[string]interface{})
|
|
transfers := data["transfers"].([]interface{})
|
|
assert.Len(t, transfers, 1)
|
|
}
|
|
|
|
func TestRetryTransfer_Success(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
mock := &mockTransferServiceAdmin{stripeTransferID: "tr_admin_retry_ok"}
|
|
handler := NewAdminTransferHandler(db, mock, 0.10, logger)
|
|
|
|
transferID := uuid.New()
|
|
sellerID := uuid.New()
|
|
orderID := uuid.New()
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: transferID,
|
|
SellerID: sellerID,
|
|
OrderID: orderID,
|
|
AmountCents: 900,
|
|
PlatformFeeCents: 100,
|
|
Currency: "EUR",
|
|
Status: "failed",
|
|
}).Error)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/admin/transfers/"+transferID.String()+"/retry", nil)
|
|
c.Params = gin.Params{{Key: "id", Value: transferID.String()}}
|
|
|
|
handler.RetryTransfer(c)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var updated marketplace.SellerTransfer
|
|
require.NoError(t, db.First(&updated, transferID).Error)
|
|
assert.Equal(t, "completed", updated.Status)
|
|
// v1.0.7 item A: admin-triggered retry also persists the Stripe
|
|
// transfer id. The worker and processSellerTransfers paths are
|
|
// already covered by their own tests; this is the third path into
|
|
// the same behavior.
|
|
assert.Equal(t, "tr_admin_retry_ok", updated.StripeTransferID)
|
|
}
|
|
|
|
func TestRetryTransfer_NotFailed(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
mock := &mockTransferServiceAdmin{}
|
|
handler := NewAdminTransferHandler(db, mock, 0.10, logger)
|
|
|
|
transferID := uuid.New()
|
|
require.NoError(t, db.Create(&marketplace.SellerTransfer{
|
|
ID: transferID,
|
|
SellerID: uuid.New(),
|
|
OrderID: uuid.New(),
|
|
AmountCents: 900,
|
|
PlatformFeeCents: 100,
|
|
Currency: "EUR",
|
|
Status: "completed",
|
|
}).Error)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/admin/transfers/"+transferID.String()+"/retry", nil)
|
|
c.Params = gin.Params{{Key: "id", Value: transferID.String()}}
|
|
|
|
handler.RetryTransfer(c)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestRetryTransfer_NotFound(t *testing.T) {
|
|
db := setupAdminTransferTestDB(t)
|
|
logger := zap.NewNop()
|
|
mock := &mockTransferServiceAdmin{}
|
|
handler := NewAdminTransferHandler(db, mock, 0.10, logger)
|
|
|
|
nonexistentID := uuid.New()
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/admin/transfers/"+nonexistentID.String()+"/retry", nil)
|
|
c.Params = gin.Params{{Key: "id", Value: nonexistentID.String()}}
|
|
|
|
handler.RetryTransfer(c)
|
|
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|