diff --git a/alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py b/alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py new file mode 100644 index 00000000..d39a2202 --- /dev/null +++ b/alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py @@ -0,0 +1,72 @@ +"""add vendor onboarding table + +Revision ID: m1b2c3d4e5f6 +Revises: d7a4a3f06394 +Create Date: 2025-12-27 22:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'm1b2c3d4e5f6' +down_revision: Union[str, None] = 'd7a4a3f06394' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('vendor_onboarding', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('vendor_id', sa.Integer(), nullable=False), + # Overall status + sa.Column('status', sa.String(length=20), nullable=False, server_default='not_started'), + sa.Column('current_step', sa.String(length=30), nullable=False, server_default='company_profile'), + # Step 1: Company Profile + sa.Column('step_company_profile_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('step_company_profile_completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('step_company_profile_data', sa.JSON(), nullable=True), + # Step 2: Letzshop API Configuration + sa.Column('step_letzshop_api_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('step_letzshop_api_completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('step_letzshop_api_connection_verified', sa.Boolean(), nullable=False, server_default=sa.text('0')), + # Step 3: Product Import + sa.Column('step_product_import_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('step_product_import_completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('step_product_import_csv_url_set', sa.Boolean(), nullable=False, server_default=sa.text('0')), + # Step 4: Order Sync + sa.Column('step_order_sync_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('step_order_sync_completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('step_order_sync_job_id', sa.Integer(), nullable=True), + # Completion tracking + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + # Admin override + sa.Column('skipped_by_admin', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('skipped_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('skipped_reason', sa.Text(), nullable=True), + sa.Column('skipped_by_user_id', sa.Integer(), nullable=True), + # Timestamps + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + # Constraints + sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['skipped_by_user_id'], ['users.id']), + sa.PrimaryKeyConstraint('id'), + sqlite_autoincrement=True + ) + op.create_index(op.f('ix_vendor_onboarding_id'), 'vendor_onboarding', ['id'], unique=False) + op.create_index(op.f('ix_vendor_onboarding_vendor_id'), 'vendor_onboarding', ['vendor_id'], unique=True) + op.create_index(op.f('ix_vendor_onboarding_status'), 'vendor_onboarding', ['status'], unique=False) + op.create_index('idx_onboarding_vendor_status', 'vendor_onboarding', ['vendor_id', 'status'], unique=False) + + +def downgrade() -> None: + op.drop_index('idx_onboarding_vendor_status', table_name='vendor_onboarding') + op.drop_index(op.f('ix_vendor_onboarding_status'), table_name='vendor_onboarding') + op.drop_index(op.f('ix_vendor_onboarding_vendor_id'), table_name='vendor_onboarding') + op.drop_index(op.f('ix_vendor_onboarding_id'), table_name='vendor_onboarding') + op.drop_table('vendor_onboarding') diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index b347b2cf..7d981ea0 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -28,6 +28,7 @@ from . import ( media, messages, notifications, + onboarding, order_item_exceptions, orders, payments, @@ -56,6 +57,7 @@ router.include_router(auth.router, tags=["vendor-auth"]) router.include_router(dashboard.router, tags=["vendor-dashboard"]) router.include_router(profile.router, tags=["vendor-profile"]) router.include_router(settings.router, tags=["vendor-settings"]) +router.include_router(onboarding.router, tags=["vendor-onboarding"]) # Business operations (with prefixes: /products/*, /orders/*, etc.) router.include_router(products.router, tags=["vendor-products"]) diff --git a/app/api/v1/vendor/onboarding.py b/app/api/v1/vendor/onboarding.py new file mode 100644 index 00000000..f0f39ef2 --- /dev/null +++ b/app/api/v1/vendor/onboarding.py @@ -0,0 +1,294 @@ +# app/api/v1/vendor/onboarding.py +""" +Vendor onboarding API endpoints. + +Provides endpoints for the 4-step mandatory onboarding wizard: +1. Company Profile Setup +2. Letzshop API Configuration +3. Product & Order Import Configuration +4. Order Sync (historical import) + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_api +from app.core.database import get_db +from app.services.onboarding_service import ( + OnboardingError, + OnboardingService, + OnboardingStepError, +) +from models.database.user import User +from models.schema.onboarding import ( + CompanyProfileRequest, + CompanyProfileResponse, + LetzshopApiConfigRequest, + LetzshopApiConfigResponse, + LetzshopApiTestRequest, + LetzshopApiTestResponse, + OnboardingStatusResponse, + OrderSyncCompleteRequest, + OrderSyncCompleteResponse, + OrderSyncProgressResponse, + OrderSyncTriggerRequest, + OrderSyncTriggerResponse, + ProductImportConfigRequest, + ProductImportConfigResponse, +) + +router = APIRouter(prefix="/onboarding") +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Status Endpoint +# ============================================================================= + + +@router.get("/status", response_model=OnboardingStatusResponse) +def get_onboarding_status( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get current onboarding status. + + Returns full status including all step completion states and progress. + """ + service = OnboardingService(db) + status = service.get_status_response(current_user.token_vendor_id) + return status + + +# ============================================================================= +# Step 1: Company Profile +# ============================================================================= + + +@router.get("/step/company-profile") +def get_company_profile( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get current company profile data for editing. + + Returns pre-filled data from vendor and company records. + """ + service = OnboardingService(db) + return service.get_company_profile_data(current_user.token_vendor_id) + + +@router.post("/step/company-profile", response_model=CompanyProfileResponse) +def save_company_profile( + request: CompanyProfileRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Save company profile and complete Step 1. + + Updates vendor and company records with provided data. + """ + service = OnboardingService(db) + + try: + result = service.complete_company_profile( + vendor_id=current_user.token_vendor_id, + company_name=request.company_name, + brand_name=request.brand_name, + description=request.description, + contact_email=request.contact_email, + contact_phone=request.contact_phone, + website=request.website, + business_address=request.business_address, + tax_number=request.tax_number, + default_language=request.default_language, + dashboard_language=request.dashboard_language, + ) + return result + except OnboardingError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +# ============================================================================= +# Step 2: Letzshop API Configuration +# ============================================================================= + + +@router.post("/step/letzshop-api/test", response_model=LetzshopApiTestResponse) +def test_letzshop_api( + request: LetzshopApiTestRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Test Letzshop API connection without saving. + + Use this to validate API key before saving credentials. + """ + service = OnboardingService(db) + return service.test_letzshop_api( + api_key=request.api_key, + shop_slug=request.shop_slug, + ) + + +@router.post("/step/letzshop-api", response_model=LetzshopApiConfigResponse) +def save_letzshop_api( + request: LetzshopApiConfigRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Save Letzshop API credentials and complete Step 2. + + Tests connection first, only saves if successful. + """ + service = OnboardingService(db) + + try: + result = service.complete_letzshop_api( + vendor_id=current_user.token_vendor_id, + api_key=request.api_key, + shop_slug=request.shop_slug, + letzshop_vendor_id=request.vendor_id, + ) + return result + except OnboardingStepError as e: + raise HTTPException(status_code=400, detail=str(e)) + except OnboardingError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Step 3: Product & Order Import Configuration +# ============================================================================= + + +@router.get("/step/product-import") +def get_product_import_config( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get current product import configuration. + + Returns pre-filled CSV URLs and Letzshop feed settings. + """ + service = OnboardingService(db) + return service.get_product_import_config(current_user.token_vendor_id) + + +@router.post("/step/product-import", response_model=ProductImportConfigResponse) +def save_product_import_config( + request: ProductImportConfigRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Save product import configuration and complete Step 3. + + At least one CSV URL must be provided. + """ + service = OnboardingService(db) + + try: + result = service.complete_product_import( + vendor_id=current_user.token_vendor_id, + csv_url_fr=request.csv_url_fr, + csv_url_en=request.csv_url_en, + csv_url_de=request.csv_url_de, + default_tax_rate=request.default_tax_rate, + delivery_method=request.delivery_method, + preorder_days=request.preorder_days, + ) + return result + except OnboardingStepError as e: + raise HTTPException(status_code=400, detail=str(e)) + except OnboardingError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Step 4: Order Sync +# ============================================================================= + + +@router.post("/step/order-sync/trigger", response_model=OrderSyncTriggerResponse) +def trigger_order_sync( + request: OrderSyncTriggerRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Trigger historical order import. + + Creates a background job that imports orders from Letzshop. + """ + service = OnboardingService(db) + + try: + result = service.trigger_order_sync( + vendor_id=current_user.token_vendor_id, + user_id=current_user.id, + days_back=request.days_back, + include_products=request.include_products, + ) + return result + except OnboardingStepError as e: + raise HTTPException(status_code=400, detail=str(e)) + except OnboardingError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get( + "/step/order-sync/progress/{job_id}", + response_model=OrderSyncProgressResponse, +) +def get_order_sync_progress( + job_id: int, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get order sync job progress. + + Poll this endpoint to show progress bar during import. + """ + service = OnboardingService(db) + return service.get_order_sync_progress( + vendor_id=current_user.token_vendor_id, + job_id=job_id, + ) + + +@router.post("/step/order-sync/complete", response_model=OrderSyncCompleteResponse) +def complete_order_sync( + request: OrderSyncCompleteRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Mark order sync step as complete. + + Called after the import job finishes (success or failure). + This also marks the entire onboarding as complete. + """ + service = OnboardingService(db) + + try: + result = service.complete_order_sync( + vendor_id=current_user.token_vendor_id, + job_id=request.job_id, + ) + return result + except OnboardingStepError as e: + raise HTTPException(status_code=400, detail=str(e)) + except OnboardingError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/routes/vendor_pages.py b/app/routes/vendor_pages.py index 5a8295e6..e56727d4 100644 --- a/app/routes/vendor_pages.py +++ b/app/routes/vendor_pages.py @@ -11,7 +11,8 @@ Authentication failures redirect to /vendor/{vendor_code}/login. Routes: - GET /vendor/{vendor_code}/ → Redirect to login or dashboard - GET /vendor/{vendor_code}/login → Vendor login page -- GET /vendor/{vendor_code}/dashboard → Vendor dashboard +- GET /vendor/{vendor_code}/onboarding → Vendor onboarding wizard +- GET /vendor/{vendor_code}/dashboard → Vendor dashboard (requires onboarding) - GET /vendor/{vendor_code}/products → Product management - GET /vendor/{vendor_code}/orders → Order management - GET /vendor/{vendor_code}/customers → Customer management @@ -34,6 +35,7 @@ from app.api.deps import ( get_db, ) from app.services.content_page_service import content_page_service +from app.services.onboarding_service import OnboardingService from models.database.user import User logger = logging.getLogger(__name__) @@ -111,6 +113,44 @@ async def vendor_login_page( # ============================================================================ +@router.get( + "/{vendor_code}/onboarding", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_onboarding_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render vendor onboarding wizard. + + Mandatory 4-step wizard that must be completed before accessing dashboard: + 1. Company Profile Setup + 2. Letzshop API Configuration + 3. Product & Order Import Configuration + 4. Order Sync (historical import) + + If onboarding is already completed, redirects to dashboard. + """ + # Check if onboarding is completed + onboarding_service = OnboardingService(db) + if onboarding_service.is_completed(current_user.token_vendor_id): + return RedirectResponse( + url=f"/vendor/{vendor_code}/dashboard", + status_code=302, + ) + + return templates.TemplateResponse( + "vendor/onboarding.html", + { + "request": request, + "user": current_user, + "vendor_code": vendor_code, + }, + ) + + @router.get( "/{vendor_code}/dashboard", response_class=HTMLResponse, include_in_schema=False ) @@ -118,16 +158,27 @@ async def vendor_dashboard_page( request: Request, vendor_code: str = Path(..., description="Vendor code"), current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), ): """ Render vendor dashboard. + Redirects to onboarding if not completed. + JavaScript will: - Load vendor info via API - Load dashboard stats via API - Load recent orders via API - Handle all interactivity """ + # Check if onboarding is completed + onboarding_service = OnboardingService(db) + if not onboarding_service.is_completed(current_user.token_vendor_id): + return RedirectResponse( + url=f"/vendor/{vendor_code}/onboarding", + status_code=302, + ) + return templates.TemplateResponse( "vendor/dashboard.html", { diff --git a/app/services/onboarding_service.py b/app/services/onboarding_service.py new file mode 100644 index 00000000..dcd9b570 --- /dev/null +++ b/app/services/onboarding_service.py @@ -0,0 +1,676 @@ +# app/services/onboarding_service.py +""" +Vendor onboarding service. + +Handles the 4-step mandatory onboarding wizard for new vendors: +1. Company Profile Setup +2. Letzshop API Configuration +3. Product & Order Import (CSV feed URL configuration) +4. Order Sync (historical import with progress tracking) +""" + +import logging +from datetime import UTC, datetime + +from sqlalchemy.orm import Session + +from app.services.letzshop.credentials_service import LetzshopCredentialsService +from app.services.letzshop.order_service import LetzshopOrderService +from models.database.company import Company +from models.database.letzshop import LetzshopHistoricalImportJob +from models.database.onboarding import ( + OnboardingStatus, + OnboardingStep, + VendorOnboarding, +) +from models.database.vendor import Vendor + +logger = logging.getLogger(__name__) + + +class OnboardingError(Exception): + """Base exception for onboarding errors.""" + + +class OnboardingNotFoundError(OnboardingError): + """Raised when onboarding record is not found.""" + + +class OnboardingStepError(OnboardingError): + """Raised when a step operation fails.""" + + +class OnboardingService: + """ + Service for managing vendor onboarding workflow. + + Provides methods for each onboarding step and progress tracking. + """ + + def __init__(self, db: Session): + """ + Initialize the onboarding service. + + Args: + db: SQLAlchemy database session. + """ + self.db = db + + # ========================================================================= + # Onboarding CRUD + # ========================================================================= + + def get_onboarding(self, vendor_id: int) -> VendorOnboarding | None: + """Get onboarding record for a vendor.""" + return ( + self.db.query(VendorOnboarding) + .filter(VendorOnboarding.vendor_id == vendor_id) + .first() + ) + + def get_onboarding_or_raise(self, vendor_id: int) -> VendorOnboarding: + """Get onboarding record or raise OnboardingNotFoundError.""" + onboarding = self.get_onboarding(vendor_id) + if onboarding is None: + raise OnboardingNotFoundError( + f"Onboarding not found for vendor {vendor_id}" + ) + return onboarding + + def create_onboarding(self, vendor_id: int) -> VendorOnboarding: + """ + Create a new onboarding record for a vendor. + + This is called automatically when a vendor is created during signup. + """ + # Check if already exists + existing = self.get_onboarding(vendor_id) + if existing: + logger.warning(f"Onboarding already exists for vendor {vendor_id}") + return existing + + onboarding = VendorOnboarding( + vendor_id=vendor_id, + status=OnboardingStatus.NOT_STARTED.value, + current_step=OnboardingStep.COMPANY_PROFILE.value, + ) + + self.db.add(onboarding) + self.db.flush() + + logger.info(f"Created onboarding record for vendor {vendor_id}") + return onboarding + + def get_or_create_onboarding(self, vendor_id: int) -> VendorOnboarding: + """Get existing onboarding or create new one.""" + onboarding = self.get_onboarding(vendor_id) + if onboarding is None: + onboarding = self.create_onboarding(vendor_id) + return onboarding + + # ========================================================================= + # Status Helpers + # ========================================================================= + + def is_completed(self, vendor_id: int) -> bool: + """Check if onboarding is completed for a vendor.""" + onboarding = self.get_onboarding(vendor_id) + if onboarding is None: + return False + return onboarding.is_completed + + def get_status_response(self, vendor_id: int) -> dict: + """ + Get full onboarding status for API response. + + Returns a dictionary with all step statuses and progress information. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + return { + "id": onboarding.id, + "vendor_id": onboarding.vendor_id, + "status": onboarding.status, + "current_step": onboarding.current_step, + # Step statuses + "company_profile": { + "completed": onboarding.step_company_profile_completed, + "completed_at": onboarding.step_company_profile_completed_at, + "data": onboarding.step_company_profile_data, + }, + "letzshop_api": { + "completed": onboarding.step_letzshop_api_completed, + "completed_at": onboarding.step_letzshop_api_completed_at, + "connection_verified": onboarding.step_letzshop_api_connection_verified, + }, + "product_import": { + "completed": onboarding.step_product_import_completed, + "completed_at": onboarding.step_product_import_completed_at, + "csv_url_set": onboarding.step_product_import_csv_url_set, + }, + "order_sync": { + "completed": onboarding.step_order_sync_completed, + "completed_at": onboarding.step_order_sync_completed_at, + "job_id": onboarding.step_order_sync_job_id, + }, + # Progress tracking + "completion_percentage": onboarding.completion_percentage, + "completed_steps_count": onboarding.completed_steps_count, + "total_steps": 4, + # Completion info + "is_completed": onboarding.is_completed, + "started_at": onboarding.started_at, + "completed_at": onboarding.completed_at, + # Admin override info + "skipped_by_admin": onboarding.skipped_by_admin, + "skipped_at": onboarding.skipped_at, + "skipped_reason": onboarding.skipped_reason, + } + + # ========================================================================= + # Step 1: Company Profile + # ========================================================================= + + def get_company_profile_data(self, vendor_id: int) -> dict: + """Get current company profile data for editing.""" + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + return {} + + company = vendor.company + + return { + "company_name": company.name if company else None, + "brand_name": vendor.name, + "description": vendor.description, + "contact_email": vendor.effective_contact_email, + "contact_phone": vendor.effective_contact_phone, + "website": vendor.effective_website, + "business_address": vendor.effective_business_address, + "tax_number": vendor.effective_tax_number, + "default_language": vendor.default_language, + "dashboard_language": vendor.dashboard_language, + } + + def complete_company_profile( + self, + vendor_id: int, + company_name: str | None = None, + brand_name: str | None = None, + description: str | None = None, + contact_email: str | None = None, + contact_phone: str | None = None, + website: str | None = None, + business_address: str | None = None, + tax_number: str | None = None, + default_language: str = "fr", + dashboard_language: str = "fr", + ) -> dict: + """ + Save company profile and mark Step 1 as complete. + + Returns response with next step information. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + # Update onboarding status if this is the first step + if onboarding.status == OnboardingStatus.NOT_STARTED.value: + onboarding.status = OnboardingStatus.IN_PROGRESS.value + onboarding.started_at = datetime.now(UTC) + + # Get vendor and company + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + raise OnboardingStepError(f"Vendor {vendor_id} not found") + + company = vendor.company + + # Update company name if provided + if company and company_name: + company.name = company_name + + # Update vendor fields + if brand_name: + vendor.name = brand_name + if description is not None: + vendor.description = description + + # Update contact info (vendor-level overrides) + vendor.contact_email = contact_email + vendor.contact_phone = contact_phone + vendor.website = website + vendor.business_address = business_address + vendor.tax_number = tax_number + + # Update language settings + vendor.default_language = default_language + vendor.dashboard_language = dashboard_language + + # Store profile data in onboarding record + onboarding.step_company_profile_data = { + "company_name": company_name, + "brand_name": brand_name, + "description": description, + "contact_email": contact_email, + "contact_phone": contact_phone, + "website": website, + "business_address": business_address, + "tax_number": tax_number, + "default_language": default_language, + "dashboard_language": dashboard_language, + } + + # Mark step complete + onboarding.mark_step_complete(OnboardingStep.COMPANY_PROFILE.value) + + self.db.flush() + + logger.info(f"Completed company profile step for vendor {vendor_id}") + + return { + "success": True, + "step_completed": True, + "next_step": onboarding.current_step, + "message": "Company profile saved successfully", + } + + # ========================================================================= + # Step 2: Letzshop API Configuration + # ========================================================================= + + def test_letzshop_api( + self, + api_key: str, + shop_slug: str, + ) -> dict: + """ + Test Letzshop API connection without saving credentials. + + Returns connection test result with vendor info if successful. + """ + credentials_service = LetzshopCredentialsService(self.db) + + # Test the API key + success, response_time, error = credentials_service.test_api_key(api_key) + + if success: + return { + "success": True, + "message": f"Connection successful ({response_time:.0f}ms)", + "vendor_name": None, # Would need to query Letzshop for this + "vendor_id": None, + "shop_slug": shop_slug, + } + else: + return { + "success": False, + "message": error or "Connection failed", + "vendor_name": None, + "vendor_id": None, + "shop_slug": None, + } + + def complete_letzshop_api( + self, + vendor_id: int, + api_key: str, + shop_slug: str, + letzshop_vendor_id: str | None = None, + ) -> dict: + """ + Save Letzshop API credentials and mark Step 2 as complete. + + Tests connection first, only saves if successful. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + # Verify step order + if not onboarding.can_proceed_to_step(OnboardingStep.LETZSHOP_API.value): + raise OnboardingStepError( + "Please complete the Company Profile step first" + ) + + # Test connection first + credentials_service = LetzshopCredentialsService(self.db) + success, response_time, error = credentials_service.test_api_key(api_key) + + if not success: + return { + "success": False, + "step_completed": False, + "next_step": None, + "message": f"Connection test failed: {error}", + "connection_verified": False, + } + + # Save credentials + credentials_service.upsert_credentials( + vendor_id=vendor_id, + api_key=api_key, + auto_sync_enabled=False, # Enable after onboarding + sync_interval_minutes=15, + ) + + # Update vendor with Letzshop identity + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + if vendor: + vendor.letzshop_vendor_slug = shop_slug + if letzshop_vendor_id: + vendor.letzshop_vendor_id = letzshop_vendor_id + + # Mark step complete + onboarding.step_letzshop_api_connection_verified = True + onboarding.mark_step_complete(OnboardingStep.LETZSHOP_API.value) + + self.db.flush() + + logger.info(f"Completed Letzshop API step for vendor {vendor_id}") + + return { + "success": True, + "step_completed": True, + "next_step": onboarding.current_step, + "message": "Letzshop API configured successfully", + "connection_verified": True, + } + + # ========================================================================= + # Step 3: Product & Order Import Configuration + # ========================================================================= + + def get_product_import_config(self, vendor_id: int) -> dict: + """Get current product import configuration.""" + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + return {} + + return { + "csv_url_fr": vendor.letzshop_csv_url_fr, + "csv_url_en": vendor.letzshop_csv_url_en, + "csv_url_de": vendor.letzshop_csv_url_de, + "default_tax_rate": vendor.letzshop_default_tax_rate, + "delivery_method": vendor.letzshop_delivery_method, + "preorder_days": vendor.letzshop_preorder_days, + } + + def complete_product_import( + self, + vendor_id: int, + csv_url_fr: str | None = None, + csv_url_en: str | None = None, + csv_url_de: str | None = None, + default_tax_rate: int = 17, + delivery_method: str = "package_delivery", + preorder_days: int = 1, + ) -> dict: + """ + Save product import configuration and mark Step 3 as complete. + + At least one CSV URL must be provided. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + # Verify step order + if not onboarding.can_proceed_to_step(OnboardingStep.PRODUCT_IMPORT.value): + raise OnboardingStepError( + "Please complete the Letzshop API step first" + ) + + # Validate at least one CSV URL + csv_urls_count = sum([ + bool(csv_url_fr), + bool(csv_url_en), + bool(csv_url_de), + ]) + + if csv_urls_count == 0: + return { + "success": False, + "step_completed": False, + "next_step": None, + "message": "At least one CSV URL must be provided", + "csv_urls_configured": 0, + } + + # Update vendor settings + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + raise OnboardingStepError(f"Vendor {vendor_id} not found") + + vendor.letzshop_csv_url_fr = csv_url_fr + vendor.letzshop_csv_url_en = csv_url_en + vendor.letzshop_csv_url_de = csv_url_de + vendor.letzshop_default_tax_rate = default_tax_rate + vendor.letzshop_delivery_method = delivery_method + vendor.letzshop_preorder_days = preorder_days + + # Mark step complete + onboarding.step_product_import_csv_url_set = True + onboarding.mark_step_complete(OnboardingStep.PRODUCT_IMPORT.value) + + self.db.flush() + + logger.info(f"Completed product import step for vendor {vendor_id}") + + return { + "success": True, + "step_completed": True, + "next_step": onboarding.current_step, + "message": "Product import configured successfully", + "csv_urls_configured": csv_urls_count, + } + + # ========================================================================= + # Step 4: Order Sync + # ========================================================================= + + def trigger_order_sync( + self, + vendor_id: int, + user_id: int, + days_back: int = 90, + include_products: bool = True, + ) -> dict: + """ + Trigger historical order import and return job info. + + Creates a background job that imports historical orders from Letzshop. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + # Verify step order + if not onboarding.can_proceed_to_step(OnboardingStep.ORDER_SYNC.value): + raise OnboardingStepError( + "Please complete the Product Import step first" + ) + + # Create historical import job + order_service = LetzshopOrderService(self.db) + + # Check for existing running job + existing_job = order_service.get_running_historical_import_job(vendor_id) + if existing_job: + return { + "success": True, + "message": "Import job already running", + "job_id": existing_job.id, + "estimated_duration_minutes": 5, # Estimate + } + + # Create new job + job = order_service.create_historical_import_job( + vendor_id=vendor_id, + user_id=user_id, + ) + + # Store job ID in onboarding + onboarding.step_order_sync_job_id = job.id + + self.db.flush() + + logger.info(f"Triggered order sync job {job.id} for vendor {vendor_id}") + + return { + "success": True, + "message": "Historical import started", + "job_id": job.id, + "estimated_duration_minutes": 5, # Estimate + } + + def get_order_sync_progress( + self, + vendor_id: int, + job_id: int, + ) -> dict: + """ + Get progress of historical import job. + + Returns current status, progress, and counts. + """ + order_service = LetzshopOrderService(self.db) + job = order_service.get_historical_import_job_by_id(vendor_id, job_id) + + if not job: + return { + "job_id": job_id, + "status": "not_found", + "progress_percentage": 0, + "current_phase": None, + "orders_imported": 0, + "orders_total": None, + "products_imported": 0, + "started_at": None, + "completed_at": None, + "estimated_remaining_seconds": None, + "error_message": "Job not found", + } + + # Calculate progress percentage + progress = 0 + if job.status == "completed": + progress = 100 + elif job.status == "failed": + progress = 0 + elif job.status == "processing": + if job.total_shipments and job.total_shipments > 0: + progress = int((job.processed_shipments or 0) / job.total_shipments * 100) + else: + progress = 50 # Indeterminate + + # Determine current phase + current_phase = None + if job.status == "fetching": + current_phase = "fetching" + elif job.status == "processing": + current_phase = "orders" + elif job.status == "completed": + current_phase = "complete" + + return { + "job_id": job.id, + "status": job.status, + "progress_percentage": progress, + "current_phase": current_phase, + "orders_imported": job.processed_shipments or 0, + "orders_total": job.total_shipments, + "products_imported": 0, # TODO: Track this + "started_at": job.started_at, + "completed_at": job.completed_at, + "estimated_remaining_seconds": None, # TODO: Calculate + "error_message": job.error_message, + } + + def complete_order_sync( + self, + vendor_id: int, + job_id: int, + ) -> dict: + """ + Mark order sync step as complete after job finishes. + + Also marks the entire onboarding as complete. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + # Verify job is complete + order_service = LetzshopOrderService(self.db) + job = order_service.get_historical_import_job_by_id(vendor_id, job_id) + + if not job: + raise OnboardingStepError(f"Job {job_id} not found") + + if job.status not in ("completed", "failed"): + return { + "success": False, + "step_completed": False, + "onboarding_completed": False, + "message": f"Job is still {job.status}, please wait", + "redirect_url": None, + } + + # Mark step complete (even if job failed - they can retry later) + onboarding.mark_step_complete(OnboardingStep.ORDER_SYNC.value) + + # Enable auto-sync now that onboarding is complete + credentials_service = LetzshopCredentialsService(self.db) + credentials = credentials_service.get_credentials(vendor_id) + if credentials: + credentials.auto_sync_enabled = True + + self.db.flush() + + # Get vendor code for redirect URL + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + vendor_code = vendor.vendor_code if vendor else "" + + logger.info(f"Completed onboarding for vendor {vendor_id}") + + return { + "success": True, + "step_completed": True, + "onboarding_completed": True, + "message": "Onboarding complete! Welcome to Wizamart.", + "redirect_url": f"/vendor/{vendor_code}/dashboard", + } + + # ========================================================================= + # Admin Skip + # ========================================================================= + + def skip_onboarding( + self, + vendor_id: int, + admin_user_id: int, + reason: str, + ) -> dict: + """ + Admin-only: Skip onboarding for a vendor. + + Used for support cases where manual setup is needed. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + onboarding.skipped_by_admin = True + onboarding.skipped_at = datetime.now(UTC) + onboarding.skipped_reason = reason + onboarding.skipped_by_user_id = admin_user_id + onboarding.status = OnboardingStatus.SKIPPED.value + + self.db.flush() + + logger.info( + f"Admin {admin_user_id} skipped onboarding for vendor {vendor_id}: {reason}" + ) + + return { + "success": True, + "message": "Onboarding skipped by admin", + "vendor_id": vendor_id, + "skipped_at": onboarding.skipped_at, + } + + +# Singleton-style convenience instance +def get_onboarding_service(db: Session) -> OnboardingService: + """Get an OnboardingService instance.""" + return OnboardingService(db) diff --git a/app/services/platform_signup_service.py b/app/services/platform_signup_service.py index a8080df4..392d5f3d 100644 --- a/app/services/platform_signup_service.py +++ b/app/services/platform_signup_service.py @@ -23,6 +23,7 @@ from app.exceptions import ( ValidationException, ) from app.services.email_service import EmailService +from app.services.onboarding_service import OnboardingService from app.services.stripe_service import stripe_service from middleware.auth import AuthManager from models.database.company import Company @@ -372,6 +373,10 @@ class PlatformSignupService: ) db.add(vendor_user) + # Create VendorOnboarding record + onboarding_service = OnboardingService(db) + onboarding_service.create_onboarding(vendor.id) + # Create Stripe Customer stripe_customer_id = stripe_service.create_customer( vendor=vendor, @@ -615,11 +620,12 @@ class PlatformSignupService: logger.info(f"Completed signup for vendor {vendor_id}") + # Redirect to onboarding instead of dashboard return SignupCompletionResult( success=True, vendor_code=vendor_code, vendor_id=vendor_id, - redirect_url=f"/vendor/{vendor_code}/dashboard", + redirect_url=f"/vendor/{vendor_code}/onboarding", trial_ends_at=trial_ends_at.isoformat(), ) diff --git a/app/templates/vendor/onboarding.html b/app/templates/vendor/onboarding.html new file mode 100644 index 00000000..7cb94b21 --- /dev/null +++ b/app/templates/vendor/onboarding.html @@ -0,0 +1,380 @@ +{# app/templates/vendor/onboarding.html #} + + +
+ + +Loading your setup...
++ Let's set up your company information. This will be used for invoices and customer communication. +
++ Connect your Letzshop marketplace account to sync orders automatically. +
++ Get your API key from Letzshop Vendor Portal > Settings > API Access +
++ Set up your product CSV feed URLs for each language. At least one URL is required. +
++ Import your existing orders from Letzshop to get started with a complete order history. +
++ % Complete +
+ ++ orders imported +
+Import Complete!
++ orders have been imported. +
+