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.
225 lines
6.4 KiB
Go
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
|
|
}
|
|
}
|