# app/modules/marketplace/services/onboarding_service.py """ Store onboarding service. Handles the 4-step mandatory onboarding wizard for new stores: 1. Merchant 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.modules.marketplace.exceptions import ( OnboardingCsvUrlRequiredException, OnboardingNotFoundException, OnboardingStepOrderException, OnboardingSyncJobNotFoundException, OnboardingSyncNotCompleteException, ) from app.modules.marketplace.models import ( OnboardingStatus, OnboardingStep, StoreOnboarding, ) from app.modules.marketplace.services.letzshop import ( LetzshopCredentialsService, LetzshopOrderService, ) from app.modules.tenancy.exceptions import StoreNotFoundException from app.modules.tenancy.models import Store logger = logging.getLogger(__name__) class OnboardingService: """ Service for managing store 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, store_id: int) -> StoreOnboarding | None: """Get onboarding record for a store.""" return ( self.db.query(StoreOnboarding) .filter(StoreOnboarding.store_id == store_id) .first() ) def get_onboarding_or_raise(self, store_id: int) -> StoreOnboarding: """Get onboarding record or raise OnboardingNotFoundException.""" onboarding = self.get_onboarding(store_id) if onboarding is None: raise OnboardingNotFoundException(store_id) return onboarding def create_onboarding(self, store_id: int) -> StoreOnboarding: """ Create a new onboarding record for a store. This is called automatically when a store is created during signup. """ # Check if already exists existing = self.get_onboarding(store_id) if existing: logger.warning(f"Onboarding already exists for store {store_id}") return existing onboarding = StoreOnboarding( store_id=store_id, status=OnboardingStatus.NOT_STARTED.value, current_step=OnboardingStep.MERCHANT_PROFILE.value, ) self.db.add(onboarding) self.db.flush() logger.info(f"Created onboarding record for store {store_id}") return onboarding def get_or_create_onboarding(self, store_id: int) -> StoreOnboarding: """Get existing onboarding or create new one.""" onboarding = self.get_onboarding(store_id) if onboarding is None: onboarding = self.create_onboarding(store_id) return onboarding # ========================================================================= # Status Helpers # ========================================================================= def is_completed(self, store_id: int) -> bool: """Check if onboarding is completed for a store.""" onboarding = self.get_onboarding(store_id) if onboarding is None: return False return onboarding.is_completed def get_status_response(self, store_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(store_id) return { "id": onboarding.id, "store_id": onboarding.store_id, "status": onboarding.status, "current_step": onboarding.current_step, # Step statuses "merchant_profile": { "completed": onboarding.step_merchant_profile_completed, "completed_at": onboarding.step_merchant_profile_completed_at, "data": onboarding.step_merchant_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: Merchant Profile # ========================================================================= def get_merchant_profile_data(self, store_id: int) -> dict: """Get current merchant profile data for editing.""" store = self.db.query(Store).filter(Store.id == store_id).first() if not store: return {} merchant = store.merchant return { "merchant_name": merchant.name if merchant else None, "brand_name": store.name, "description": store.description, "contact_email": store.effective_contact_email, "contact_phone": store.effective_contact_phone, "website": store.effective_website, "business_address": store.effective_business_address, "tax_number": store.effective_tax_number, "default_language": store.default_language, "dashboard_language": store.dashboard_language, } def complete_merchant_profile( self, store_id: int, merchant_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 merchant profile and mark Step 1 as complete. Returns response with next step information. """ # Check store exists BEFORE creating onboarding record (FK constraint) store = self.db.query(Store).filter(Store.id == store_id).first() if not store: raise StoreNotFoundException(store_id) onboarding = self.get_or_create_onboarding(store_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) merchant = store.merchant # Update merchant name if provided if merchant and merchant_name: merchant.name = merchant_name # Update store fields if brand_name: store.name = brand_name if description is not None: store.description = description # Update contact info (store-level overrides) store.contact_email = contact_email store.contact_phone = contact_phone store.website = website store.business_address = business_address store.tax_number = tax_number # Update language settings store.default_language = default_language store.dashboard_language = dashboard_language # Store profile data in onboarding record onboarding.step_merchant_profile_data = { "merchant_name": merchant_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.MERCHANT_PROFILE.value) self.db.flush() logger.info(f"Completed merchant profile step for store {store_id}") return { "success": True, "step_completed": True, "next_step": onboarding.current_step, "message": "Merchant 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 store 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)", "store_name": None, # Would need to query Letzshop for this "store_id": None, "shop_slug": shop_slug, } return { "success": False, "message": error or "Connection failed", "store_name": None, "store_id": None, "shop_slug": None, } def complete_letzshop_api( self, store_id: int, api_key: str, shop_slug: str, letzshop_store_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(store_id) # Verify step order if not onboarding.can_proceed_to_step(OnboardingStep.LETZSHOP_API.value): raise OnboardingStepOrderException( current_step=onboarding.current_step, required_step=OnboardingStep.MERCHANT_PROFILE.value, ) # 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( store_id=store_id, api_key=api_key, auto_sync_enabled=False, # Enable after onboarding sync_interval_minutes=15, ) # Update store with Letzshop identity store = self.db.query(Store).filter(Store.id == store_id).first() if store: store.letzshop_store_slug = shop_slug if letzshop_store_id: store.letzshop_store_id = letzshop_store_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 store {store_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, store_id: int) -> dict: """Get current product import configuration.""" store = self.db.query(Store).filter(Store.id == store_id).first() if not store: return {} return { "csv_url_fr": store.letzshop_csv_url_fr, "csv_url_en": store.letzshop_csv_url_en, "csv_url_de": store.letzshop_csv_url_de, "default_tax_rate": store.letzshop_default_tax_rate, "delivery_method": store.letzshop_delivery_method, "preorder_days": store.letzshop_preorder_days, } def complete_product_import( self, store_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(store_id) # Verify step order if not onboarding.can_proceed_to_step(OnboardingStep.PRODUCT_IMPORT.value): raise OnboardingStepOrderException( current_step=onboarding.current_step, required_step=OnboardingStep.LETZSHOP_API.value, ) # 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: raise OnboardingCsvUrlRequiredException() # Update store settings store = self.db.query(Store).filter(Store.id == store_id).first() if not store: raise StoreNotFoundException(store_id) store.letzshop_csv_url_fr = csv_url_fr store.letzshop_csv_url_en = csv_url_en store.letzshop_csv_url_de = csv_url_de store.letzshop_default_tax_rate = default_tax_rate store.letzshop_delivery_method = delivery_method store.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 store {store_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, store_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(store_id) # Verify step order if not onboarding.can_proceed_to_step(OnboardingStep.ORDER_SYNC.value): raise OnboardingStepOrderException( current_step=onboarding.current_step, required_step=OnboardingStep.PRODUCT_IMPORT.value, ) # Create historical import job order_service = LetzshopOrderService(self.db) # Check for existing running job existing_job = order_service.get_running_historical_import_job(store_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( store_id=store_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 store {store_id}") return { "success": True, "message": "Historical import started", "job_id": job.id, "estimated_duration_minutes": 5, # Estimate } def get_order_sync_progress( self, store_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(store_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": # Use orders_processed and shipments_fetched for progress total = job.shipments_fetched or 0 processed = job.orders_processed or 0 if total > 0: progress = int(processed / total * 100) else: progress = 50 # Indeterminate elif job.status == "fetching": # Show partial progress during fetch phase if job.total_pages and job.total_pages > 0: progress = int((job.current_page or 0) / job.total_pages * 50) else: progress = 25 # Indeterminate # Determine current phase current_phase = job.current_phase or job.status return { "job_id": job.id, "status": job.status, "progress_percentage": progress, "current_phase": current_phase, "orders_imported": job.orders_imported or 0, "orders_total": job.shipments_fetched, "products_imported": job.products_matched or 0, "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, store_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(store_id) # Verify job is complete order_service = LetzshopOrderService(self.db) job = order_service.get_historical_import_job_by_id(store_id, job_id) if not job: raise OnboardingSyncJobNotFoundException(job_id) if job.status not in ("completed", "failed"): raise OnboardingSyncNotCompleteException(job.status) # 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(store_id) if credentials: credentials.auto_sync_enabled = True self.db.flush() # Get store code for redirect URL store = self.db.query(Store).filter(Store.id == store_id).first() store_code = store.store_code if store else "" logger.info(f"Completed onboarding for store {store_id}") return { "success": True, "step_completed": True, "onboarding_completed": True, "message": "Onboarding complete! Welcome to Orion.", "redirect_url": f"/store/{store_code}/dashboard", } # ========================================================================= # Admin Skip # ========================================================================= def skip_onboarding( self, store_id: int, admin_user_id: int, reason: str, ) -> dict: """ Admin-only: Skip onboarding for a store. Used for support cases where manual setup is needed. """ onboarding = self.get_or_create_onboarding(store_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 store {store_id}: {reason}" ) return { "success": True, "message": "Onboarding skipped by admin", "store_id": store_id, "skipped_at": onboarding.skipped_at, } # Singleton-style convenience instance def get_onboarding_service(db: Session) -> OnboardingService: """Get an OnboardingService instance.""" return OnboardingService(db)