veza/veza-backend-api/internal/handlers/admin_transfer_handler_test.go

251 lines
7.2 KiB
Go
Raw Normal View History

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 {
refactor(connect): persist stripe_transfer_id on create + retry — v1.0.7 item A TransferService.CreateTransfer signature changes from (...) error to (...) (string, error) — the caller now captures the Stripe transfer identifier and persists it on the SellerTransfer row. Pre-v1.0.7 the stripe_transfer_id column was declared on the model and table but never written to, which blocked the reversal worker (v1.0.7 item B) from identifying which transfer to reverse on refund. Changes: * `TransferService` interface and `StripeConnectService.CreateTransfer` both return the Stripe transfer id alongside the error. * `processSellerTransfers` (marketplace service) persists the id on success before `tx.Create(&st)` so a crash between Stripe ACK and DB commit leaves no inconsistency. * `TransferRetryWorker.retryOne` persists on retry success — a row that failed on first attempt and succeeded via the worker is reversal-ready all the same. * `admin_transfer_handler.RetryTransfer` (manual retry) persists too. * `SellerPayout.ExternalPayoutID` is populated by the Connect payout flow (`payout.go`) — the field existed but was never written. * Four test mocks updated; two tests assert the id is persisted on the happy path, one on the failure path confirms we don't write a fake id when the provider errors. Migration `981_seller_transfers_stripe_reversal_id.sql`: * Adds nullable `stripe_reversal_id` column for item B. * Partial UNIQUE indexes on both stripe_transfer_id and stripe_reversal_id (WHERE IS NOT NULL AND <> ''), mirroring the v1.0.6.1 pattern for refunds.hyperswitch_refund_id. * Logs a count of historical completed transfers that lack an id — these are candidates for the backfill CLI follow-up task. Backfill for historical rows is a separate follow-up (cmd/tools/ backfill_stripe_transfer_ids, calling Stripe's transfers.List with Destination + Metadata[order_id]). Pre-v1.0.7 transfers without a backfilled id cannot be auto-reversed on refund — document in P2.9 admin-recovery when it lands. Acceptable scope per v107-plan. Migration number bumped 980 → 981 because v1.0.6.2 used 980 for the unpaid-subscription cleanup; v107-plan updated with the note. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:08:39 +00:00
err error
stripeTransferID string
}
refactor(connect): persist stripe_transfer_id on create + retry — v1.0.7 item A TransferService.CreateTransfer signature changes from (...) error to (...) (string, error) — the caller now captures the Stripe transfer identifier and persists it on the SellerTransfer row. Pre-v1.0.7 the stripe_transfer_id column was declared on the model and table but never written to, which blocked the reversal worker (v1.0.7 item B) from identifying which transfer to reverse on refund. Changes: * `TransferService` interface and `StripeConnectService.CreateTransfer` both return the Stripe transfer id alongside the error. * `processSellerTransfers` (marketplace service) persists the id on success before `tx.Create(&st)` so a crash between Stripe ACK and DB commit leaves no inconsistency. * `TransferRetryWorker.retryOne` persists on retry success — a row that failed on first attempt and succeeded via the worker is reversal-ready all the same. * `admin_transfer_handler.RetryTransfer` (manual retry) persists too. * `SellerPayout.ExternalPayoutID` is populated by the Connect payout flow (`payout.go`) — the field existed but was never written. * Four test mocks updated; two tests assert the id is persisted on the happy path, one on the failure path confirms we don't write a fake id when the provider errors. Migration `981_seller_transfers_stripe_reversal_id.sql`: * Adds nullable `stripe_reversal_id` column for item B. * Partial UNIQUE indexes on both stripe_transfer_id and stripe_reversal_id (WHERE IS NOT NULL AND <> ''), mirroring the v1.0.6.1 pattern for refunds.hyperswitch_refund_id. * Logs a count of historical completed transfers that lack an id — these are candidates for the backfill CLI follow-up task. Backfill for historical rows is a separate follow-up (cmd/tools/ backfill_stripe_transfer_ids, calling Stripe's transfers.List with Destination + Metadata[order_id]). Pre-v1.0.7 transfers without a backfilled id cannot be auto-reversed on refund — document in P2.9 admin-recovery when it lands. Acceptable scope per v107-plan. Migration number bumped 980 → 981 because v1.0.6.2 used 980 for the unpaid-subscription cleanup; v107-plan updated with the note. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:08:39 +00:00
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
}
feat(marketplace): async stripe connect reversal worker — v1.0.7 item B day 2 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>
2026-04-17 13:34:29 +00:00
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)
}