veza/veza-backend-api/internal/services/stripe_connect_service.go
2026-03-05 23:03:43 +01:00

209 lines
6.3 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)
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
}