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) func (s *StripeConnectService) CreateTransfer(ctx context.Context, sellerUserID uuid.UUID, amount int64, currency, orderID 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) } _, err := transfer.New(params) if err != nil { return fmt.Errorf("create stripe transfer: %w", err) } return nil }