veza/veza-backend-api/internal/services/kyc_service.go
senke a1000ce7fb style(backend): gofmt -w on 85 files (whitespace only)
backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.

The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
2026-04-14 12:22:14 +02:00

225 lines
6.4 KiB
Go

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
}
}