package services import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "github.com/stripe/stripe-go/v82" "github.com/stripe/stripe-go/v82/identity/verificationsession" "go.uber.org/zap" "gorm.io/gorm" "veza-backend-api/internal/models" ) var ( ErrKYCNotAvailable = errors.New("KYC verification is not available") ErrKYCAlreadyDone = errors.New("seller is already verified") ErrKYCPending = errors.New("verification is already in progress") ) // KYCStatus constants const ( KYCStatusNotStarted = "not_started" KYCStatusPending = "pending" KYCStatusVerified = "verified" KYCStatusFailed = "failed" ) // KYCService handles Stripe Identity verification for seller KYC type KYCService struct { db *gorm.DB secretKey string logger *zap.Logger } // NewKYCService creates a new KYC service func NewKYCService(db *gorm.DB, secretKey string, logger *zap.Logger) *KYCService { return &KYCService{ db: db, secretKey: secretKey, logger: logger, } } // KYCSessionResponse contains the verification session info for the frontend type KYCSessionResponse struct { SessionID string `json:"session_id"` ClientSecret string `json:"client_secret"` Status string `json:"status"` URL string `json:"url"` } // CreateVerificationSession creates a Stripe Identity VerificationSession for a seller func (s *KYCService) CreateVerificationSession(ctx context.Context, userID uuid.UUID, returnURL string) (*KYCSessionResponse, error) { if s.secretKey == "" { return nil, ErrKYCNotAvailable } stripe.Key = s.secretKey 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 nil, ErrNoStripeAccount } return nil, err } if sa.KYCStatus == KYCStatusVerified { return nil, ErrKYCAlreadyDone } if sa.KYCStatus == KYCStatusPending && sa.KYCVerificationSessionID != "" { // Return existing pending session session, err := verificationsession.Get(sa.KYCVerificationSessionID, nil) if err == nil && session.Status == "requires_input" { return &KYCSessionResponse{ SessionID: session.ID, ClientSecret: session.ClientSecret, Status: string(session.Status), URL: session.URL, }, nil } // Session expired or invalid — create a new one } // Get user info var user models.User if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil { return nil, fmt.Errorf("user not found: %w", err) } // Create verification session params := &stripe.IdentityVerificationSessionParams{ Type: stripe.String(string(stripe.IdentityVerificationSessionTypeDocument)), Options: &stripe.IdentityVerificationSessionOptionsParams{ Document: &stripe.IdentityVerificationSessionOptionsDocumentParams{ AllowedTypes: []*string{ stripe.String("driving_license"), stripe.String("passport"), stripe.String("id_card"), }, RequireMatchingSelfie: stripe.Bool(true), }, }, ReturnURL: stripe.String(returnURL), } params.AddMetadata("user_id", userID.String()) params.AddMetadata("stripe_account_id", sa.StripeAccountID) session, err := verificationsession.New(params) if err != nil { return nil, fmt.Errorf("create verification session: %w", err) } // Update DB sa.KYCStatus = KYCStatusPending sa.KYCVerificationSessionID = session.ID sa.KYCLastError = "" if err := s.db.WithContext(ctx).Save(&sa).Error; err != nil { return nil, fmt.Errorf("update kyc status: %w", err) } s.logger.Info("KYC verification session created", zap.String("user_id", userID.String()), zap.String("session_id", session.ID), ) return &KYCSessionResponse{ SessionID: session.ID, ClientSecret: session.ClientSecret, Status: string(session.Status), URL: session.URL, }, nil } // GetVerificationStatus checks and updates the KYC verification status func (s *KYCService) GetVerificationStatus(ctx context.Context, userID uuid.UUID) (string, error) { if s.secretKey == "" { return KYCStatusNotStarted, ErrKYCNotAvailable } stripe.Key = s.secretKey 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 KYCStatusNotStarted, nil } return "", err } // If already verified or not started, return directly if sa.KYCStatus == KYCStatusVerified || sa.KYCStatus == KYCStatusNotStarted { return sa.KYCStatus, nil } // For pending/failed, check Stripe for latest status if sa.KYCVerificationSessionID != "" { session, err := verificationsession.Get(sa.KYCVerificationSessionID, nil) if err != nil { return sa.KYCStatus, nil // Return cached status on error } newStatus := mapStripeVerificationStatus(string(session.Status)) if newStatus != sa.KYCStatus { sa.KYCStatus = newStatus if newStatus == KYCStatusVerified { now := time.Now() sa.KYCVerifiedAt = &now } if session.LastError != nil { sa.KYCLastError = string(session.LastError.Code) } s.db.WithContext(ctx).Save(&sa) } return newStatus, nil } return sa.KYCStatus, nil } // HandleVerificationWebhook processes Stripe Identity webhook events func (s *KYCService) HandleVerificationWebhook(ctx context.Context, sessionID string, status string) error { var sa models.SellerStripeAccount if err := s.db.WithContext(ctx).Where("kyc_verification_session_id = ?", sessionID).First(&sa).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { s.logger.Warn("KYC webhook for unknown session", zap.String("session_id", sessionID)) return nil } return err } newStatus := mapStripeVerificationStatus(status) sa.KYCStatus = newStatus if newStatus == KYCStatusVerified { now := time.Now() sa.KYCVerifiedAt = &now } if err := s.db.WithContext(ctx).Save(&sa).Error; err != nil { return fmt.Errorf("update kyc from webhook: %w", err) } s.logger.Info("KYC verification status updated via webhook", zap.String("user_id", sa.UserID.String()), zap.String("session_id", sessionID), zap.String("status", newStatus), ) return nil } func mapStripeVerificationStatus(stripeStatus string) string { switch stripeStatus { case "verified": return KYCStatusVerified case "requires_input", "processing": return KYCStatusPending case "canceled": return KYCStatusFailed default: return KYCStatusPending } }