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>
147 lines
4.4 KiB
Go
147 lines
4.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
|
|
"veza-backend-api/internal/core/marketplace"
|
|
apperrors "veza-backend-api/internal/errors"
|
|
)
|
|
|
|
// AdminTransferHandler handles admin transfer dashboard endpoints (v0.701).
|
|
type AdminTransferHandler struct {
|
|
db *gorm.DB
|
|
ts marketplace.TransferService
|
|
logger *zap.Logger
|
|
feeRate float64
|
|
}
|
|
|
|
// NewAdminTransferHandler creates a new AdminTransferHandler.
|
|
func NewAdminTransferHandler(db *gorm.DB, ts marketplace.TransferService, feeRate float64, logger *zap.Logger) *AdminTransferHandler {
|
|
return &AdminTransferHandler{
|
|
db: db,
|
|
ts: ts,
|
|
logger: logger,
|
|
feeRate: feeRate,
|
|
}
|
|
}
|
|
|
|
// GetTransfers returns a paginated list of all platform transfers with optional filters.
|
|
// Query params: status, seller_id, from, to, limit (default 50), offset (default 0).
|
|
func (h *AdminTransferHandler) GetTransfers(c *gin.Context) {
|
|
query := h.db.Model(&marketplace.SellerTransfer{})
|
|
|
|
if status := c.Query("status"); status != "" {
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
if sellerIDStr := c.Query("seller_id"); sellerIDStr != "" {
|
|
sellerID, err := uuid.Parse(sellerIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid seller_id"))
|
|
return
|
|
}
|
|
query = query.Where("seller_id = ?", sellerID)
|
|
}
|
|
if from := c.Query("from"); from != "" {
|
|
query = query.Where("created_at >= ?", from)
|
|
}
|
|
if to := c.Query("to"); to != "" {
|
|
query = query.Where("created_at <= ?", to)
|
|
}
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
h.logger.Error("GetTransfers count failed", zap.Error(err))
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to count transfers", err))
|
|
return
|
|
}
|
|
|
|
limit := 50
|
|
if l := c.Query("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
offset := 0
|
|
if o := c.Query("offset"); o != "" {
|
|
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
|
offset = parsed
|
|
}
|
|
}
|
|
|
|
var transfers []marketplace.SellerTransfer
|
|
if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&transfers).Error; err != nil {
|
|
h.logger.Error("GetTransfers find failed", zap.Error(err))
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to fetch transfers", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"transfers": transfers,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
// RetryTransfer manually retries a failed transfer.
|
|
func (h *AdminTransferHandler) RetryTransfer(c *gin.Context) {
|
|
if h.ts == nil {
|
|
RespondWithAppError(c, apperrors.NewServiceUnavailableError("Stripe Connect is not enabled"))
|
|
return
|
|
}
|
|
|
|
idStr := c.Param("id")
|
|
if idStr == "" {
|
|
RespondWithAppError(c, apperrors.NewValidationError("transfer id required"))
|
|
return
|
|
}
|
|
transferID, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid transfer id"))
|
|
return
|
|
}
|
|
|
|
var t marketplace.SellerTransfer
|
|
if err := h.db.First(&t, transferID).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("transfer"))
|
|
return
|
|
}
|
|
h.logger.Error("RetryTransfer find failed", zap.Error(err), zap.String("transfer_id", idStr))
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to fetch transfer", err))
|
|
return
|
|
}
|
|
|
|
if t.Status != "failed" {
|
|
RespondWithAppError(c, apperrors.NewValidationError("only failed transfers can be retried"))
|
|
return
|
|
}
|
|
|
|
stripeTransferID, err := h.ts.CreateTransfer(c.Request.Context(), t.SellerID, t.AmountCents, t.Currency, t.OrderID.String())
|
|
if err != nil {
|
|
t.RetryCount++
|
|
t.ErrorMessage = err.Error()
|
|
// Could set next_retry_at for worker to pick up later, but for manual retry we just record the failure
|
|
if saveErr := h.db.Save(&t).Error; saveErr != nil {
|
|
h.logger.Error("RetryTransfer save failed", zap.Error(saveErr))
|
|
}
|
|
h.logger.Error("RetryTransfer CreateTransfer failed", zap.Error(err), zap.String("transfer_id", idStr))
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Transfer failed", err))
|
|
return
|
|
}
|
|
|
|
t.Status = "completed"
|
|
t.ErrorMessage = ""
|
|
t.StripeTransferID = stripeTransferID
|
|
if err := h.db.Save(&t).Error; err != nil {
|
|
h.logger.Error("RetryTransfer save failed", zap.Error(err))
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to update transfer", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, t)
|
|
}
|