veza/veza-backend-api/internal/services/stripe_connect_service.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

212 lines
6.5 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/stripe/stripe-go/v82"
"github.com/stripe/stripe-go/v82/account"
"github.com/stripe/stripe-go/v82/accountlink"
"github.com/stripe/stripe-go/v82/balance"
"github.com/stripe/stripe-go/v82/transfer"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/models"
)
var (
ErrStripeConnectDisabled = errors.New("Stripe Connect is not enabled")
ErrNoStripeAccount = errors.New("seller has no Stripe Connect account")
)
// BalanceResponse is the balance for a connected account
type BalanceResponse struct {
Connected bool `json:"connected"` // true if seller has completed Stripe Connect onboarding
Available int64 `json:"available"` // in minor units (cents)
Pending int64 `json:"pending"` // in minor units (cents)
}
// StripeConnectService handles Stripe Connect operations for seller payouts
type StripeConnectService struct {
db *gorm.DB
secretKey string
logger *zap.Logger
}
// NewStripeConnectService creates a new Stripe Connect service
func NewStripeConnectService(db *gorm.DB, secretKey string, logger *zap.Logger) *StripeConnectService {
return &StripeConnectService{
db: db,
secretKey: secretKey,
logger: logger,
}
}
// CreateOnboardingLink creates or retrieves a Stripe Express account and returns an onboarding URL
func (s *StripeConnectService) CreateOnboardingLink(ctx context.Context, userID uuid.UUID, returnURL, refreshURL string) (string, error) {
if s.secretKey == "" {
return "", ErrStripeConnectDisabled
}
stripe.Key = s.secretKey
// Get user email
var user models.User
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", fmt.Errorf("user not found: %w", err)
}
return "", err
}
// Check if account already exists
var existing models.SellerStripeAccount
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&existing).Error; err == nil {
// Account exists, create new account link for onboarding/update
params := &stripe.AccountLinkParams{
Account: stripe.String(existing.StripeAccountID),
ReturnURL: stripe.String(returnURL),
RefreshURL: stripe.String(refreshURL),
Type: stripe.String(string(stripe.AccountLinkTypeAccountOnboarding)),
}
link, err := accountlink.New(params)
if err != nil {
return "", fmt.Errorf("create account link: %w", err)
}
return link.URL, nil
}
// Create new Express account
accountParams := &stripe.AccountParams{
Type: stripe.String(string(stripe.AccountTypeExpress)),
Email: stripe.String(user.Email),
}
acct, err := account.New(accountParams)
if err != nil {
return "", fmt.Errorf("create stripe account: %w", err)
}
// Save to DB
sa := models.SellerStripeAccount{
UserID: userID,
StripeAccountID: acct.ID,
OnboardingCompleted: false,
}
if err := s.db.WithContext(ctx).Create(&sa).Error; err != nil {
return "", fmt.Errorf("save seller stripe account: %w", err)
}
// Create account link
linkParams := &stripe.AccountLinkParams{
Account: stripe.String(acct.ID),
ReturnURL: stripe.String(returnURL),
RefreshURL: stripe.String(refreshURL),
Type: stripe.String(string(stripe.AccountLinkTypeAccountOnboarding)),
}
link, err := accountlink.New(linkParams)
if err != nil {
return "", fmt.Errorf("create account link: %w", err)
}
return link.URL, nil
}
// HandleOnboardingCallback updates the account status after Stripe redirect
func (s *StripeConnectService) HandleOnboardingCallback(ctx context.Context, userID uuid.UUID) error {
var sa models.SellerStripeAccount
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&sa).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNoStripeAccount
}
return err
}
if s.secretKey == "" {
return ErrStripeConnectDisabled
}
stripe.Key = s.secretKey
// Retrieve account from Stripe to get charges_enabled, payouts_enabled
params := &stripe.AccountParams{}
acct, err := account.GetByID(sa.StripeAccountID, params)
if err != nil {
return fmt.Errorf("get stripe account: %w", err)
}
sa.ChargesEnabled = acct.ChargesEnabled
sa.PayoutsEnabled = acct.PayoutsEnabled
sa.OnboardingCompleted = acct.DetailsSubmitted
if err := s.db.WithContext(ctx).Save(&sa).Error; err != nil {
return fmt.Errorf("update seller stripe account: %w", err)
}
return nil
}
// GetBalance returns the balance for a seller's connected account
func (s *StripeConnectService) GetBalance(ctx context.Context, userID uuid.UUID) (*BalanceResponse, error) {
var sa models.SellerStripeAccount
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&sa).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &BalanceResponse{Connected: false, Available: 0, Pending: 0}, nil
}
return nil, err
}
if s.secretKey == "" {
return nil, ErrStripeConnectDisabled
}
stripe.Key = s.secretKey
params := &stripe.BalanceParams{}
params.SetStripeAccount(sa.StripeAccountID)
bal, err := balance.Get(params)
if err != nil {
return nil, fmt.Errorf("get stripe balance: %w", err)
}
var available, pending int64
for _, a := range bal.Available {
available += a.Amount
}
for _, p := range bal.Pending {
pending += p.Amount
}
return &BalanceResponse{Connected: true, Available: available, Pending: pending}, nil
}
// CreateTransfer transfers funds to a connected account (for payout on sale).
// Returns the Stripe transfer identifier (tr_*) on success so the caller can
// persist it on the SellerTransfer row — required by item B (reversal worker)
// and by reconciliation against the Stripe dashboard.
func (s *StripeConnectService) CreateTransfer(ctx context.Context, sellerUserID uuid.UUID, amount int64, currency, orderID string) (string, error) {
var sa models.SellerStripeAccount
if err := s.db.WithContext(ctx).Where("user_id = ?", sellerUserID).First(&sa).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", ErrNoStripeAccount
}
return "", err
}
if !sa.PayoutsEnabled {
return "", fmt.Errorf("seller account does not have payouts enabled")
}
if s.secretKey == "" {
return "", ErrStripeConnectDisabled
}
stripe.Key = s.secretKey
params := &stripe.TransferParams{
Amount: stripe.Int64(amount),
Currency: stripe.String(currency),
Destination: stripe.String(sa.StripeAccountID),
}
if orderID != "" {
params.AddMetadata("order_id", orderID)
}
tr, err := transfer.New(params)
if err != nil {
return "", fmt.Errorf("create stripe transfer: %w", err)
}
return tr.ID, nil
}