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