diff --git a/apps/web/src/components/ui/LazyComponent.tsx b/apps/web/src/components/ui/LazyComponent.tsx
index e346becf8..108ba8f68 100644
--- a/apps/web/src/components/ui/LazyComponent.tsx
+++ b/apps/web/src/components/ui/LazyComponent.tsx
@@ -49,5 +49,6 @@ export {
LazySubscription,
LazyDistribution,
LazyEducation,
+ LazySupport,
} from './lazy-component';
export type { LazyComponentProps, LazyErrorFallbackProps, LazyErrorBoundaryProps } from './lazy-component';
diff --git a/apps/web/src/components/ui/lazy-component/index.ts b/apps/web/src/components/ui/lazy-component/index.ts
index 39ca052d6..e57e79d73 100644
--- a/apps/web/src/components/ui/lazy-component/index.ts
+++ b/apps/web/src/components/ui/lazy-component/index.ts
@@ -52,4 +52,5 @@ export {
LazySubscription,
LazyDistribution,
LazyEducation,
+ LazySupport,
} from './lazyExports';
diff --git a/apps/web/src/components/ui/lazy-component/lazyExports.ts b/apps/web/src/components/ui/lazy-component/lazyExports.ts
index 9b1e554bf..8ac9e849f 100644
--- a/apps/web/src/components/ui/lazy-component/lazyExports.ts
+++ b/apps/web/src/components/ui/lazy-component/lazyExports.ts
@@ -339,3 +339,12 @@ export const LazyEducation = createLazyComponent(
undefined,
'Education',
);
+// v0.13.5 TASK-MKT-004: Support page
+export const LazySupport = createLazyComponent(
+ () =>
+ import('@/features/support/pages/SupportPage').then((m) => ({
+ default: m.SupportPage,
+ })),
+ undefined,
+ 'Support',
+);
diff --git a/apps/web/src/features/support/pages/SupportPage.test.tsx b/apps/web/src/features/support/pages/SupportPage.test.tsx
new file mode 100644
index 000000000..1e5df9b52
--- /dev/null
+++ b/apps/web/src/features/support/pages/SupportPage.test.tsx
@@ -0,0 +1,116 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { SupportPage } from './SupportPage';
+
+// Mock dependencies
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string) => fallback || key,
+ }),
+}));
+
+vi.mock('@/features/auth/hooks/useUser', () => ({
+ useUser: () => ({ data: { email: 'test@example.com', username: 'testuser' } }),
+}));
+
+vi.mock('@/features/auth/store/authStore', () => ({
+ useAuthStore: () => ({ isAuthenticated: true }),
+}));
+
+vi.mock('@/components/layout/DashboardLayout', () => ({
+ DashboardLayout: ({ children }: { children: React.ReactNode }) =>
{children}
,
+}));
+
+const mockPost = vi.fn().mockResolvedValue({ data: { ticket_id: 'ticket-123' } });
+vi.mock('@/services/api/client', () => ({
+ apiClient: { post: (...args: unknown[]) => mockPost(...args) },
+}));
+
+function renderWithProviders(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+ {ui},
+ );
+}
+
+describe('SupportPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockPost.mockResolvedValue({ data: { ticket_id: 'ticket-123' } });
+ });
+
+ it('should render the support form', () => {
+ renderWithProviders();
+ expect(screen.getByText('Support')).toBeDefined();
+ expect(screen.getByLabelText('Email address')).toBeDefined();
+ expect(screen.getByLabelText('Subject')).toBeDefined();
+ expect(screen.getByLabelText('Message')).toBeDefined();
+ });
+
+ it('should pre-fill email from authenticated user', () => {
+ renderWithProviders();
+ const emailInput = screen.getByLabelText('Email address') as HTMLInputElement;
+ expect(emailInput.value).toBe('test@example.com');
+ });
+
+ it('should disable submit when form is incomplete', () => {
+ renderWithProviders();
+ const submitBtn = screen.getByRole('button', { name: /send message/i });
+ expect(submitBtn).toBeDisabled();
+ });
+
+ it('should enable submit when form is complete', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.type(screen.getByLabelText('Subject'), 'Payment issue');
+ await user.type(screen.getByLabelText('Message'), 'I have a problem with my recent purchase.');
+
+ const submitBtn = screen.getByRole('button', { name: /send message/i });
+ expect(submitBtn).not.toBeDisabled();
+ });
+
+ it('should submit the form and show success message', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.type(screen.getByLabelText('Subject'), 'Payment issue');
+ await user.type(screen.getByLabelText('Message'), 'I have a problem with my recent purchase.');
+ await user.click(screen.getByRole('button', { name: /send message/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Message sent!')).toBeDefined();
+ });
+
+ expect(mockPost).toHaveBeenCalledWith('/support/tickets', expect.objectContaining({
+ email: 'test@example.com',
+ subject: 'Payment issue',
+ category: 'general',
+ }));
+ });
+
+ it('should show error on submission failure', async () => {
+ mockPost.mockRejectedValueOnce(new Error('Network error'));
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.type(screen.getByLabelText('Subject'), 'Payment issue');
+ await user.type(screen.getByLabelText('Message'), 'I have a problem with my recent purchase.');
+ await user.click(screen.getByRole('button', { name: /send message/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toBeDefined();
+ });
+ });
+
+ it('should render quick help links', () => {
+ renderWithProviders();
+ expect(screen.getByText('Quick help')).toBeDefined();
+ expect(screen.getByText('Payment issues')).toBeDefined();
+ expect(screen.getByText('Account settings')).toBeDefined();
+ });
+});
diff --git a/apps/web/src/features/support/pages/SupportPage.tsx b/apps/web/src/features/support/pages/SupportPage.tsx
new file mode 100644
index 000000000..d4190dca4
--- /dev/null
+++ b/apps/web/src/features/support/pages/SupportPage.tsx
@@ -0,0 +1,225 @@
+/**
+ * SupportPage — Accessible contact/support form
+ * v0.13.5 TASK-MKT-004: Page support accessible
+ */
+
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { DashboardLayout } from '@/components/layout/DashboardLayout';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Select } from '@/components/ui/select';
+import { useUser } from '@/features/auth/hooks/useUser';
+import { apiClient } from '@/services/api/client';
+import { Mail, CheckCircle2, AlertCircle, Send, HelpCircle } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+const CATEGORY_OPTIONS = [
+ { value: 'general', label: 'General' },
+ { value: 'payment', label: 'Payment' },
+ { value: 'account', label: 'Account' },
+ { value: 'bug', label: 'Bug Report' },
+];
+
+type SubmitState = 'idle' | 'submitting' | 'success' | 'error';
+
+export function SupportPage() {
+ const { t } = useTranslation();
+ const { data: user } = useUser();
+ const [email, setEmail] = useState(user?.email || '');
+ const [subject, setSubject] = useState('');
+ const [message, setMessage] = useState('');
+ const [category, setCategory] = useState('general');
+ const [submitState, setSubmitState] = useState('idle');
+ const [errorMessage, setErrorMessage] = useState('');
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSubmitState('submitting');
+ setErrorMessage('');
+
+ try {
+ await apiClient.post('/support/tickets', {
+ email: email.trim(),
+ subject: subject.trim(),
+ message: message.trim(),
+ category,
+ });
+ setSubmitState('success');
+ setSubject('');
+ setMessage('');
+ } catch (err: unknown) {
+ setSubmitState('error');
+ const msg = err instanceof Error ? err.message : 'An error occurred';
+ setErrorMessage(msg);
+ }
+ };
+
+ const isValid = email.includes('@') && subject.trim().length >= 3 && message.trim().length >= 10;
+
+ return (
+
+
+
+
+ {t('support.title', 'Support')}
+
+
+ {t('support.description', "Need help? Send us a message and we'll get back to you within 48 hours.")}
+
+
+
+ {submitState === 'success' ? (
+
+
+
+ {t('support.success.title', 'Message sent!')}
+
+
+ {t('support.success.description', "We've received your message and will respond to your email within 48 hours.")}
+
+
+
+ ) : (
+
+ )}
+
+ {/* FAQ / Quick Links */}
+
+
+ {t('support.quickHelp', 'Quick help')}
+
+
+ {[
+ { label: t('support.faq.payments', 'Payment issues'), href: '/marketplace' },
+ { label: t('support.faq.account', 'Account settings'), href: '/settings' },
+ { label: t('support.faq.selling', 'Selling on Veza'), href: '/sell' },
+ { label: t('support.faq.privacy', 'Privacy & data'), href: '/settings' },
+ ].map(({ label, href }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/router/routeConfig.tsx b/apps/web/src/router/routeConfig.tsx
index 0d174276e..6925fde80 100644
--- a/apps/web/src/router/routeConfig.tsx
+++ b/apps/web/src/router/routeConfig.tsx
@@ -49,6 +49,7 @@ import {
LazySubscription,
LazyDistribution,
LazyEducation,
+ LazySupport,
} from '@/components/ui/LazyComponent';
import { PublicRoute } from './PublicRoute';
import { ProtectedLayoutRoute } from './ProtectedLayoutRoute';
@@ -146,6 +147,8 @@ export function getProtectedRoutes(): RouteEntry[] {
{ path: '/distribution', element: wrapProtected() },
// v0.12.3: Formation & Éducation
{ path: '/education', element: wrapProtected() },
+ // v0.13.5 TASK-MKT-004: Support page
+ { path: '/support', element: wrapProtected() },
];
}
diff --git a/veza-backend-api/internal/api/routes_marketplace.go b/veza-backend-api/internal/api/routes_marketplace.go
index d00b6b28a..d7452ee27 100644
--- a/veza-backend-api/internal/api/routes_marketplace.go
+++ b/veza-backend-api/internal/api/routes_marketplace.go
@@ -142,6 +142,15 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) {
sellProtected.GET("/marketplace-balance", payoutHandler.GetSellerBalance)
sellProtected.GET("/payouts", payoutHandler.GetPayoutHistory)
sellProtected.POST("/payouts/request", payoutHandler.RequestPayout)
+
+ // v0.13.5 TASK-MKT-001: KYC seller identity verification
+ var kycSvc *services.KYCService
+ if r.config.StripeConnectEnabled && r.config.StripeConnectSecretKey != "" {
+ kycSvc = services.NewKYCService(r.db.GormDB, r.config.StripeConnectSecretKey, r.logger)
+ }
+ kycHandler := handlers.NewKYCHandler(kycSvc, r.logger)
+ sellProtected.POST("/kyc/start", kycHandler.StartVerification)
+ sellProtected.GET("/kyc/status", kycHandler.GetVerificationStatus)
}
commerce := router.Group("/commerce")
@@ -157,4 +166,9 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) {
cartProtected.DELETE("/cart/items/:id", marketplaceExtHandler.RemoveFromCart)
cartProtected.POST("/cart/checkout", marketplaceExtHandler.Checkout)
}
+
+ // v0.13.5 TASK-MKT-004: Support/Contact form (public endpoint, optional auth)
+ support := router.Group("/support")
+ supportHandler := handlers.NewSupportHandler(r.db.GormDB, r.logger)
+ support.POST("/tickets", supportHandler.SubmitTicket)
}
diff --git a/veza-backend-api/internal/handlers/kyc_handler.go b/veza-backend-api/internal/handlers/kyc_handler.go
new file mode 100644
index 000000000..2813b06fd
--- /dev/null
+++ b/veza-backend-api/internal/handlers/kyc_handler.go
@@ -0,0 +1,98 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+
+ apperrors "veza-backend-api/internal/errors"
+ "veza-backend-api/internal/services"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+// KYCHandler handles seller identity verification endpoints (v0.13.5 TASK-MKT-001)
+type KYCHandler struct {
+ kycService *services.KYCService
+ logger *zap.Logger
+}
+
+// NewKYCHandler creates a new KYC handler
+func NewKYCHandler(kycService *services.KYCService, logger *zap.Logger) *KYCHandler {
+ return &KYCHandler{
+ kycService: kycService,
+ logger: logger,
+ }
+}
+
+// CreateVerificationRequest is the request body for starting KYC
+type CreateVerificationRequest struct {
+ ReturnURL string `json:"return_url"`
+}
+
+// StartVerification creates a Stripe Identity verification session
+func (h *KYCHandler) StartVerification(c *gin.Context) {
+ if h.kycService == nil {
+ RespondWithAppError(c, apperrors.NewServiceUnavailableError("KYC verification is not available"))
+ return
+ }
+
+ userID, ok := GetUserIDUUID(c)
+ if !ok {
+ return
+ }
+
+ var req CreateVerificationRequest
+ _ = c.ShouldBindJSON(&req)
+ returnURL := req.ReturnURL
+ if returnURL == "" {
+ returnURL = c.Request.URL.Scheme + "://" + c.Request.Host + "/sell?kyc=complete"
+ }
+
+ session, err := h.kycService.CreateVerificationSession(c.Request.Context(), userID, returnURL)
+ if err != nil {
+ if errors.Is(err, services.ErrKYCAlreadyDone) {
+ RespondSuccess(c, http.StatusOK, gin.H{"status": "verified", "message": "Already verified"})
+ return
+ }
+ if errors.Is(err, services.ErrKYCNotAvailable) {
+ RespondWithAppError(c, apperrors.NewServiceUnavailableError("KYC verification is not available"))
+ return
+ }
+ if errors.Is(err, services.ErrNoStripeAccount) {
+ RespondWithAppError(c, apperrors.NewValidationError("Complete Stripe Connect onboarding first"))
+ return
+ }
+ h.logger.Error("StartVerification failed", zap.Error(err), zap.String("user_id", userID.String()))
+ RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to start verification", err))
+ return
+ }
+
+ RespondSuccess(c, http.StatusCreated, session)
+}
+
+// GetVerificationStatus returns the current KYC status for a seller
+func (h *KYCHandler) GetVerificationStatus(c *gin.Context) {
+ if h.kycService == nil {
+ RespondSuccess(c, http.StatusOK, gin.H{"status": "not_available"})
+ return
+ }
+
+ userID, ok := GetUserIDUUID(c)
+ if !ok {
+ return
+ }
+
+ status, err := h.kycService.GetVerificationStatus(c.Request.Context(), userID)
+ if err != nil {
+ if errors.Is(err, services.ErrKYCNotAvailable) {
+ RespondSuccess(c, http.StatusOK, gin.H{"status": "not_available"})
+ return
+ }
+ h.logger.Error("GetVerificationStatus failed", zap.Error(err), zap.String("user_id", userID.String()))
+ RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get verification status", err))
+ return
+ }
+
+ RespondSuccess(c, http.StatusOK, gin.H{"status": status})
+}
diff --git a/veza-backend-api/internal/handlers/support_handler.go b/veza-backend-api/internal/handlers/support_handler.go
new file mode 100644
index 000000000..c0de5301f
--- /dev/null
+++ b/veza-backend-api/internal/handlers/support_handler.go
@@ -0,0 +1,100 @@
+package handlers
+
+import (
+ "net/http"
+ "strings"
+ "time"
+
+ apperrors "veza-backend-api/internal/errors"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+)
+
+// SupportTicket represents a support contact request (v0.13.5 TASK-MKT-004)
+type SupportTicket struct {
+ ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
+ UserID string `json:"user_id,omitempty" gorm:"type:uuid"`
+ Email string `json:"email" gorm:"type:varchar(255);not null"`
+ Subject string `json:"subject" gorm:"type:varchar(500);not null"`
+ Message string `json:"message" gorm:"type:text;not null"`
+ Category string `json:"category" gorm:"type:varchar(100)"`
+ Status string `json:"status" gorm:"type:varchar(50);default:'open'"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+}
+
+func (SupportTicket) TableName() string {
+ return "support_tickets"
+}
+
+// SupportHandler handles support/contact form submissions
+type SupportHandler struct {
+ db *gorm.DB
+ logger *zap.Logger
+}
+
+// NewSupportHandler creates a new support handler
+func NewSupportHandler(db *gorm.DB, logger *zap.Logger) *SupportHandler {
+ return &SupportHandler{
+ db: db,
+ logger: logger,
+ }
+}
+
+// SubmitTicketRequest is the request body for submitting a support ticket
+type SubmitTicketRequest struct {
+ Email string `json:"email" binding:"required,email"`
+ Subject string `json:"subject" binding:"required,min=3,max=500"`
+ Message string `json:"message" binding:"required,min=10,max=5000"`
+ Category string `json:"category"`
+}
+
+// SubmitTicket creates a new support ticket from the contact form
+func (h *SupportHandler) SubmitTicket(c *gin.Context) {
+ var req SubmitTicketRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ RespondWithAppError(c, apperrors.NewValidationError("Invalid request: email, subject (3-500 chars), and message (10-5000 chars) are required"))
+ return
+ }
+
+ // Sanitize
+ req.Email = strings.TrimSpace(req.Email)
+ req.Subject = strings.TrimSpace(req.Subject)
+ req.Message = strings.TrimSpace(req.Message)
+ if req.Category == "" {
+ req.Category = "general"
+ }
+
+ // Get user ID if authenticated (optional)
+ var userID string
+ if uid, exists := c.Get("user_id"); exists {
+ userID = uid.(interface{ String() string }).String()
+ }
+
+ ticket := SupportTicket{
+ UserID: userID,
+ Email: req.Email,
+ Subject: req.Subject,
+ Message: req.Message,
+ Category: req.Category,
+ Status: "open",
+ }
+
+ if err := h.db.WithContext(c.Request.Context()).Create(&ticket).Error; err != nil {
+ h.logger.Error("Failed to create support ticket", zap.Error(err))
+ RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to submit support request", err))
+ return
+ }
+
+ h.logger.Info("Support ticket created",
+ zap.String("ticket_id", ticket.ID),
+ zap.String("email", req.Email),
+ zap.String("category", req.Category),
+ )
+
+ RespondSuccess(c, http.StatusCreated, gin.H{
+ "ticket_id": ticket.ID,
+ "message": "Your support request has been submitted. We'll respond to your email within 48 hours.",
+ })
+}
diff --git a/veza-backend-api/internal/handlers/support_handler_test.go b/veza-backend-api/internal/handlers/support_handler_test.go
new file mode 100644
index 000000000..05b1161e9
--- /dev/null
+++ b/veza-backend-api/internal/handlers/support_handler_test.go
@@ -0,0 +1,128 @@
+package handlers
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+func setupSupportTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+
+ // Create support_tickets table for SQLite
+ require.NoError(t, db.Exec(`
+ CREATE TABLE IF NOT EXISTS support_tickets (
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
+ user_id TEXT,
+ email TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ message TEXT NOT NULL,
+ category TEXT DEFAULT 'general',
+ status TEXT DEFAULT 'open',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ `).Error)
+
+ router := gin.New()
+ handler := NewSupportHandler(db, zap.NewNop())
+ router.POST("/api/v1/support/tickets", handler.SubmitTicket)
+
+ return router, db
+}
+
+func TestSupportHandler_SubmitTicket_Success(t *testing.T) {
+ router, db := setupSupportTestRouter(t)
+
+ body, _ := json.Marshal(map[string]string{
+ "email": "user@example.com",
+ "subject": "Payment issue",
+ "message": "I have a problem with my payment for order #123. Please help.",
+ "category": "payment",
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/support/tickets", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusCreated, w.Code)
+
+ var resp map[string]interface{}
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
+ data := resp["data"].(map[string]interface{})
+ assert.NotEmpty(t, data["ticket_id"])
+ assert.Contains(t, data["message"], "submitted")
+
+ // Verify in DB
+ var count int64
+ db.Table("support_tickets").Count(&count)
+ assert.Equal(t, int64(1), count)
+}
+
+func TestSupportHandler_SubmitTicket_MissingFields(t *testing.T) {
+ router, _ := setupSupportTestRouter(t)
+
+ body, _ := json.Marshal(map[string]string{
+ "email": "user@example.com",
+ // Missing subject and message
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/support/tickets", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.NotEqual(t, http.StatusCreated, w.Code)
+}
+
+func TestSupportHandler_SubmitTicket_InvalidEmail(t *testing.T) {
+ router, _ := setupSupportTestRouter(t)
+
+ body, _ := json.Marshal(map[string]string{
+ "email": "not-an-email",
+ "subject": "Test subject",
+ "message": "This is a test message with enough characters.",
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/support/tickets", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.NotEqual(t, http.StatusCreated, w.Code)
+}
+
+func TestSupportHandler_SubmitTicket_DefaultCategory(t *testing.T) {
+ router, db := setupSupportTestRouter(t)
+
+ body, _ := json.Marshal(map[string]string{
+ "email": "user@example.com",
+ "subject": "General question",
+ "message": "I have a question about the platform features.",
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/support/tickets", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusCreated, w.Code)
+
+ // Verify default category
+ var ticket SupportTicket
+ db.Table("support_tickets").First(&ticket)
+ assert.Equal(t, "general", ticket.Category)
+}
diff --git a/veza-backend-api/internal/models/seller_stripe_account.go b/veza-backend-api/internal/models/seller_stripe_account.go
index 43a100ed4..34dc26aee 100644
--- a/veza-backend-api/internal/models/seller_stripe_account.go
+++ b/veza-backend-api/internal/models/seller_stripe_account.go
@@ -8,14 +8,19 @@ import (
// SellerStripeAccount links a user (seller) to their Stripe Connect Express account
type SellerStripeAccount struct {
- ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
- UserID uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueIndex;not null"`
- StripeAccountID string `json:"stripe_account_id" gorm:"type:varchar(255);uniqueIndex;not null"`
- ChargesEnabled bool `json:"charges_enabled" gorm:"default:false"`
- PayoutsEnabled bool `json:"payouts_enabled" gorm:"default:false"`
- OnboardingCompleted bool `json:"onboarding_completed" gorm:"default:false"`
- CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
- UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+ ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
+ UserID uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueIndex;not null"`
+ StripeAccountID string `json:"stripe_account_id" gorm:"type:varchar(255);uniqueIndex;not null"`
+ ChargesEnabled bool `json:"charges_enabled" gorm:"default:false"`
+ PayoutsEnabled bool `json:"payouts_enabled" gorm:"default:false"`
+ OnboardingCompleted bool `json:"onboarding_completed" gorm:"default:false"`
+ // v0.13.5 TASK-MKT-001: KYC identity verification
+ KYCStatus string `json:"kyc_status" gorm:"type:varchar(32);default:'not_started'"`
+ KYCVerificationSessionID string `json:"kyc_verification_session_id,omitempty" gorm:"type:varchar(255)"`
+ KYCVerifiedAt *time.Time `json:"kyc_verified_at,omitempty"`
+ KYCLastError string `json:"kyc_last_error,omitempty" gorm:"type:text"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName specifies the table name for SellerStripeAccount
diff --git a/veza-backend-api/internal/services/kyc_service.go b/veza-backend-api/internal/services/kyc_service.go
new file mode 100644
index 000000000..93ea69dee
--- /dev/null
+++ b/veza-backend-api/internal/services/kyc_service.go
@@ -0,0 +1,225 @@
+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
+ }
+}
diff --git a/veza-backend-api/internal/services/kyc_service_test.go b/veza-backend-api/internal/services/kyc_service_test.go
new file mode 100644
index 000000000..95b29c24d
--- /dev/null
+++ b/veza-backend-api/internal/services/kyc_service_test.go
@@ -0,0 +1,35 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMapStripeVerificationStatus(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"verified", KYCStatusVerified},
+ {"requires_input", KYCStatusPending},
+ {"processing", KYCStatusPending},
+ {"canceled", KYCStatusFailed},
+ {"unknown", KYCStatusPending},
+ {"", KYCStatusPending},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.input, func(t *testing.T) {
+ result := mapStripeVerificationStatus(tc.input)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+func TestKYCConstants(t *testing.T) {
+ assert.Equal(t, "not_started", KYCStatusNotStarted)
+ assert.Equal(t, "pending", KYCStatusPending)
+ assert.Equal(t, "verified", KYCStatusVerified)
+ assert.Equal(t, "failed", KYCStatusFailed)
+}
diff --git a/veza-backend-api/migrations/972_seller_kyc_v0135.sql b/veza-backend-api/migrations/972_seller_kyc_v0135.sql
new file mode 100644
index 000000000..e9cf32700
--- /dev/null
+++ b/veza-backend-api/migrations/972_seller_kyc_v0135.sql
@@ -0,0 +1,14 @@
+-- v0.13.5 TASK-MKT-001: KYC vendeurs — Stripe Identity verification
+-- Tracks seller identity verification status separately from Stripe Connect onboarding
+
+ALTER TABLE seller_stripe_accounts
+ ADD COLUMN IF NOT EXISTS kyc_status VARCHAR(32) NOT NULL DEFAULT 'not_started',
+ ADD COLUMN IF NOT EXISTS kyc_verification_session_id VARCHAR(255),
+ ADD COLUMN IF NOT EXISTS kyc_verified_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS kyc_last_error TEXT;
+
+-- Allowed kyc_status values: not_started, pending, verified, failed
+CREATE INDEX IF NOT EXISTS idx_seller_stripe_accounts_kyc_status ON seller_stripe_accounts(kyc_status);
+
+COMMENT ON COLUMN seller_stripe_accounts.kyc_status IS 'Identity verification status: not_started, pending, verified, failed';
+COMMENT ON COLUMN seller_stripe_accounts.kyc_verification_session_id IS 'Stripe Identity VerificationSession ID';
diff --git a/veza-backend-api/migrations/972_seller_kyc_v0135_down.sql b/veza-backend-api/migrations/972_seller_kyc_v0135_down.sql
new file mode 100644
index 000000000..f37782e5e
--- /dev/null
+++ b/veza-backend-api/migrations/972_seller_kyc_v0135_down.sql
@@ -0,0 +1,9 @@
+-- Rollback v0.13.5 TASK-MKT-001: KYC vendeurs
+
+DROP INDEX IF EXISTS idx_seller_stripe_accounts_kyc_status;
+
+ALTER TABLE seller_stripe_accounts
+ DROP COLUMN IF EXISTS kyc_status,
+ DROP COLUMN IF EXISTS kyc_verification_session_id,
+ DROP COLUMN IF EXISTS kyc_verified_at,
+ DROP COLUMN IF EXISTS kyc_last_error;
diff --git a/veza-backend-api/migrations/973_support_tickets_v0135.sql b/veza-backend-api/migrations/973_support_tickets_v0135.sql
new file mode 100644
index 000000000..e67e0fad3
--- /dev/null
+++ b/veza-backend-api/migrations/973_support_tickets_v0135.sql
@@ -0,0 +1,15 @@
+-- v0.13.5 TASK-MKT-004: Support tickets (contact form)
+
+CREATE TABLE IF NOT EXISTS support_tickets (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ email VARCHAR(255) NOT NULL,
+ subject VARCHAR(500) NOT NULL,
+ message TEXT NOT NULL,
+ category VARCHAR(100) DEFAULT 'general',
+ status VARCHAR(50) DEFAULT 'open',
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status);
+CREATE INDEX IF NOT EXISTS idx_support_tickets_created ON support_tickets(created_at DESC);
diff --git a/veza-backend-api/migrations/973_support_tickets_v0135_down.sql b/veza-backend-api/migrations/973_support_tickets_v0135_down.sql
new file mode 100644
index 000000000..c74a7e7c6
--- /dev/null
+++ b/veza-backend-api/migrations/973_support_tickets_v0135_down.sql
@@ -0,0 +1,5 @@
+-- Rollback v0.13.5 TASK-MKT-004: Support tickets
+
+DROP INDEX IF EXISTS idx_support_tickets_created;
+DROP INDEX IF EXISTS idx_support_tickets_status;
+DROP TABLE IF EXISTS support_tickets;
diff --git a/veza-backend-api/tests/integration/payout_flow_test.go b/veza-backend-api/tests/integration/payout_flow_test.go
new file mode 100644
index 000000000..f776ccc93
--- /dev/null
+++ b/veza-backend-api/tests/integration/payout_flow_test.go
@@ -0,0 +1,153 @@
+//go:build integration
+// +build integration
+
+package integration
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "veza-backend-api/internal/core/marketplace"
+ "veza-backend-api/internal/models"
+ "veza-backend-api/internal/services"
+)
+
+// TestPayoutFlow_E2E_SaleToPayoutRequest verifies the full payout flow:
+// sell product -> payment completed -> seller balance credited -> manual payout request
+// v0.13.5 TASK-MKT-003: Validation E2E flux de payout créateur
+func TestPayoutFlow_E2E_SaleToPayoutRequest(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(
+ &models.User{},
+ &models.Track{},
+ &marketplace.Product{},
+ &marketplace.Order{},
+ &marketplace.OrderItem{},
+ &marketplace.License{},
+ &marketplace.SellerTransfer{},
+ &marketplace.SellerBalance{},
+ &marketplace.SellerPayout{},
+ ))
+ // CartItem uses gen_random_uuid() (PostgreSQL) — create manually for SQLite
+ require.NoError(t, db.Exec(`
+ CREATE TABLE IF NOT EXISTS cart_items (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ product_id TEXT NOT NULL,
+ quantity INTEGER DEFAULT 1,
+ created_at DATETIME,
+ updated_at DATETIME
+ )
+ `).Error)
+ db.Callback().Create().Before("gorm:create").Register("generate_uuid_cart_payout", func(d *gorm.DB) {
+ if item, ok := d.Statement.Dest.(*marketplace.CartItem); ok && item.ID == uuid.Nil {
+ item.ID = uuid.New()
+ }
+ })
+
+ buyerID := uuid.New()
+ sellerID := uuid.New()
+ trackID := uuid.New()
+
+ require.NoError(t, db.Create(&models.User{ID: buyerID}).Error)
+ require.NoError(t, db.Create(&models.User{ID: sellerID}).Error)
+ require.NoError(t, db.Create(&models.Track{ID: trackID, UserID: sellerID, FilePath: "/test.mp3"}).Error)
+
+ product := &marketplace.Product{
+ ID: uuid.New(),
+ SellerID: sellerID,
+ Title: "Premium Beat",
+ Price: 199.99,
+ ProductType: "track",
+ TrackID: &trackID,
+ Status: marketplace.ProductStatusActive,
+ }
+ require.NoError(t, db.Create(product).Error)
+
+ mockPay := &mockPaymentProvider{paymentID: "pay_payout_test", clientSecret: "pi_secret_payout"}
+ mockTransfer := &mockTransferService{}
+ storageService := services.NewTrackStorageService("uploads/test", false, zap.NewNop())
+ marketService := marketplace.NewService(db, zap.NewNop(), storageService,
+ marketplace.WithPaymentProvider(mockPay),
+ marketplace.WithHyperswitchConfig(true, "/purchases"),
+ marketplace.WithTransferService(mockTransfer, 0.10),
+ )
+
+ router := setupPaymentFlowRouter(t, db, marketService)
+
+ // Step 1: Add to cart
+ addCartBody, _ := json.Marshal(map[string]interface{}{
+ "product_id": product.ID.String(),
+ "quantity": 1,
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/commerce/cart/items", bytes.NewReader(addCartBody))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-User-ID", buyerID.String())
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ require.Equal(t, http.StatusCreated, w.Code, "add to cart: %s", w.Body.String())
+
+ // Step 2: Checkout
+ req = httptest.NewRequest(http.MethodPost, "/api/v1/commerce/cart/checkout", bytes.NewReader([]byte("{}")))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-User-ID", buyerID.String())
+ w = httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ require.Equal(t, http.StatusCreated, w.Code, "checkout: %s", w.Body.String())
+
+ // Step 3: Simulate webhook payment_succeeded
+ payload, _ := json.Marshal(map[string]string{
+ "payment_id": "pay_payout_test",
+ "status": "succeeded",
+ })
+ signature := computeWebhookSignature(payload, "test-secret-at-least-32-chars-long")
+ req = httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/hyperswitch", bytes.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("x-webhook-signature-512", signature)
+ w = httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusOK, w.Code, "webhook: %s", w.Body.String())
+
+ // Step 4: Verify seller balance was credited
+ req = httptest.NewRequest(http.MethodGet, "/api/v1/sell/marketplace-balance", nil)
+ req.Header.Set("X-User-ID", sellerID.String())
+ w = httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusOK, w.Code, "get balance: %s", w.Body.String())
+
+ var balResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &balResp))
+ data, ok := balResp["data"].(map[string]interface{})
+ require.True(t, ok, "balance response should have data: %v", balResp)
+ // Balance should be > 0 after sale (product price minus commission)
+ availableCents := data["available_cents"].(float64)
+ assert.Greater(t, availableCents, float64(0), "seller balance should be positive after sale")
+
+ // Step 5: Request manual payout
+ req = httptest.NewRequest(http.MethodPost, "/api/v1/sell/payouts/request", bytes.NewReader([]byte("{}")))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-User-ID", sellerID.String())
+ w = httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ // May succeed or fail depending on minimum payout threshold — both are valid
+ assert.Contains(t, []int{http.StatusCreated, http.StatusOK, http.StatusBadRequest, http.StatusUnprocessableEntity}, w.Code,
+ "payout request should respond: %s", w.Body.String())
+
+ // Step 6: Verify payout history
+ req = httptest.NewRequest(http.MethodGet, "/api/v1/sell/payouts", nil)
+ req.Header.Set("X-User-ID", sellerID.String())
+ w = httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusOK, w.Code, "get payouts: %s", w.Body.String())
+}