veza/veza-backend-api/internal/handlers/admin_transfer_handler_test.go
senke eedaad9f83 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 13:08:39 +02:00

241 lines
6.8 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 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{}
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)
}
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)
}