# app/services/platform_signup_service.py """ Platform signup service. Handles all database operations for the platform signup flow: - Session management - Vendor claiming - Account creation - Subscription setup """ import logging import secrets from datetime import UTC, datetime, timedelta from dataclasses import dataclass from sqlalchemy.orm import Session from app.core.config import settings from app.exceptions import ( ConflictException, ResourceNotFoundException, 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 from models.database.subscription import ( SubscriptionStatus, TierCode, TIER_LIMITS, VendorSubscription, ) from models.database.user import User from models.database.vendor import Vendor, VendorUser, VendorUserType logger = logging.getLogger(__name__) # ============================================================================= # In-memory signup session storage # In production, use Redis or database table # ============================================================================= _signup_sessions: dict[str, dict] = {} def _create_session_id() -> str: """Generate a secure session ID.""" return secrets.token_urlsafe(32) # ============================================================================= # Data Classes # ============================================================================= @dataclass class SignupSessionData: """Data stored in a signup session.""" session_id: str step: str tier_code: str is_annual: bool created_at: str updated_at: str | None = None letzshop_slug: str | None = None letzshop_vendor_id: str | None = None vendor_name: str | None = None user_id: int | None = None vendor_id: int | None = None vendor_code: str | None = None stripe_customer_id: str | None = None setup_intent_id: str | None = None @dataclass class AccountCreationResult: """Result of account creation.""" user_id: int vendor_id: int vendor_code: str stripe_customer_id: str @dataclass class SignupCompletionResult: """Result of signup completion.""" success: bool vendor_code: str vendor_id: int redirect_url: str trial_ends_at: str # ============================================================================= # Platform Signup Service # ============================================================================= class PlatformSignupService: """Service for handling platform signup operations.""" def __init__(self): self.auth_manager = AuthManager() # ========================================================================= # Session Management # ========================================================================= def create_session(self, tier_code: str, is_annual: bool) -> str: """ Create a new signup session. Args: tier_code: The subscription tier code is_annual: Whether annual billing is selected Returns: The session ID Raises: ValidationException: If tier code is invalid """ # Validate tier code try: tier = TierCode(tier_code) except ValueError: raise ValidationException( message=f"Invalid tier code: {tier_code}", field="tier_code", ) session_id = _create_session_id() now = datetime.now(UTC).isoformat() _signup_sessions[session_id] = { "step": "tier_selected", "tier_code": tier.value, "is_annual": is_annual, "created_at": now, "updated_at": now, } logger.info(f"Created signup session {session_id} for tier {tier.value}") return session_id def get_session(self, session_id: str) -> dict | None: """Get a signup session by ID.""" return _signup_sessions.get(session_id) def get_session_or_raise(self, session_id: str) -> dict: """ Get a signup session or raise an exception. Raises: ResourceNotFoundException: If session not found """ session = self.get_session(session_id) if not session: raise ResourceNotFoundException( resource_type="SignupSession", identifier=session_id, ) return session def update_session(self, session_id: str, data: dict) -> None: """Update signup session data.""" session = self.get_session_or_raise(session_id) session.update(data) session["updated_at"] = datetime.now(UTC).isoformat() _signup_sessions[session_id] = session def delete_session(self, session_id: str) -> None: """Delete a signup session.""" _signup_sessions.pop(session_id, None) # ========================================================================= # Vendor Claiming # ========================================================================= def check_vendor_claimed(self, db: Session, letzshop_slug: str) -> bool: """Check if a Letzshop vendor is already claimed.""" return ( db.query(Vendor) .filter( Vendor.letzshop_vendor_slug == letzshop_slug, Vendor.is_active == True, ) .first() is not None ) def claim_vendor( self, db: Session, session_id: str, letzshop_slug: str, letzshop_vendor_id: str | None = None, ) -> str: """ Claim a Letzshop vendor for signup. Args: db: Database session session_id: Signup session ID letzshop_slug: Letzshop vendor slug letzshop_vendor_id: Optional Letzshop vendor ID Returns: Generated vendor name Raises: ResourceNotFoundException: If session not found ConflictException: If vendor already claimed """ session = self.get_session_or_raise(session_id) # Check if vendor is already claimed if self.check_vendor_claimed(db, letzshop_slug): raise ConflictException( message="This Letzshop vendor is already claimed", ) # Generate vendor name from slug vendor_name = letzshop_slug.replace("-", " ").title() # Update session self.update_session(session_id, { "letzshop_slug": letzshop_slug, "letzshop_vendor_id": letzshop_vendor_id, "vendor_name": vendor_name, "step": "vendor_claimed", }) logger.info(f"Claimed vendor {letzshop_slug} for session {session_id}") return vendor_name # ========================================================================= # Account Creation # ========================================================================= def check_email_exists(self, db: Session, email: str) -> bool: """Check if an email already exists.""" return db.query(User).filter(User.email == email).first() is not None def generate_unique_username(self, db: Session, email: str) -> str: """Generate a unique username from email.""" username = email.split("@")[0] base_username = username counter = 1 while db.query(User).filter(User.username == username).first(): username = f"{base_username}_{counter}" counter += 1 return username def generate_unique_vendor_code(self, db: Session, company_name: str) -> str: """Generate a unique vendor code from company name.""" vendor_code = company_name.upper().replace(" ", "_")[:20] base_code = vendor_code counter = 1 while db.query(Vendor).filter(Vendor.vendor_code == vendor_code).first(): vendor_code = f"{base_code}_{counter}" counter += 1 return vendor_code def generate_unique_subdomain(self, db: Session, company_name: str) -> str: """Generate a unique subdomain from company name.""" subdomain = company_name.lower().replace(" ", "-") subdomain = "".join(c for c in subdomain if c.isalnum() or c == "-")[:50] base_subdomain = subdomain counter = 1 while db.query(Vendor).filter(Vendor.subdomain == subdomain).first(): subdomain = f"{base_subdomain}-{counter}" counter += 1 return subdomain def create_account( self, db: Session, session_id: str, email: str, password: str, first_name: str, last_name: str, company_name: str, phone: str | None = None, ) -> AccountCreationResult: """ Create user, company, vendor, and Stripe customer. Args: db: Database session session_id: Signup session ID email: User email password: User password first_name: User first name last_name: User last name company_name: Company name phone: Optional phone number Returns: AccountCreationResult with IDs Raises: ResourceNotFoundException: If session not found ConflictException: If email already exists """ session = self.get_session_or_raise(session_id) # Check if email already exists if self.check_email_exists(db, email): raise ConflictException( message="An account with this email already exists", ) # Generate unique username username = self.generate_unique_username(db, email) # Create User user = User( email=email, username=username, hashed_password=self.auth_manager.hash_password(password), first_name=first_name, last_name=last_name, role="vendor", is_active=True, ) db.add(user) db.flush() # Create Company company = Company( name=company_name, owner_user_id=user.id, contact_email=email, contact_phone=phone, ) db.add(company) db.flush() # Generate unique vendor code and subdomain vendor_code = self.generate_unique_vendor_code(db, company_name) subdomain = self.generate_unique_subdomain(db, company_name) # Create Vendor vendor = Vendor( company_id=company.id, vendor_code=vendor_code, subdomain=subdomain, name=company_name, contact_email=email, contact_phone=phone, is_active=True, letzshop_vendor_slug=session.get("letzshop_slug"), letzshop_vendor_id=session.get("letzshop_vendor_id"), ) db.add(vendor) db.flush() # Create VendorUser (owner) vendor_user = VendorUser( vendor_id=vendor.id, user_id=user.id, user_type=VendorUserType.OWNER.value, is_active=True, ) 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, email=email, name=f"{first_name} {last_name}", metadata={ "company_name": company_name, "tier": session.get("tier_code"), }, ) # Create VendorSubscription (trial status) now = datetime.now(UTC) trial_end = now + timedelta(days=settings.stripe_trial_days) subscription = VendorSubscription( vendor_id=vendor.id, tier=session.get("tier_code", TierCode.ESSENTIAL.value), status=SubscriptionStatus.TRIAL.value, period_start=now, period_end=trial_end, trial_ends_at=trial_end, is_annual=session.get("is_annual", False), stripe_customer_id=stripe_customer_id, ) db.add(subscription) db.commit() # noqa: SVC-006 - Atomic account creation needs commit # Update session self.update_session(session_id, { "user_id": user.id, "vendor_id": vendor.id, "vendor_code": vendor_code, "stripe_customer_id": stripe_customer_id, "step": "account_created", }) logger.info( f"Created account for {email}: user_id={user.id}, vendor_id={vendor.id}" ) return AccountCreationResult( user_id=user.id, vendor_id=vendor.id, vendor_code=vendor_code, stripe_customer_id=stripe_customer_id, ) # ========================================================================= # Payment Setup # ========================================================================= def setup_payment(self, session_id: str) -> tuple[str, str]: """ Create Stripe SetupIntent for card collection. Args: session_id: Signup session ID Returns: Tuple of (client_secret, stripe_customer_id) Raises: EntityNotFoundException: If session not found ValidationException: If account not created yet """ session = self.get_session_or_raise(session_id) if "stripe_customer_id" not in session: raise ValidationException( message="Account not created. Please complete step 3 first.", field="session_id", ) stripe_customer_id = session["stripe_customer_id"] # Create SetupIntent setup_intent = stripe_service.create_setup_intent( customer_id=stripe_customer_id, metadata={ "session_id": session_id, "vendor_id": str(session.get("vendor_id")), "tier": session.get("tier_code"), }, ) # Update session self.update_session(session_id, { "setup_intent_id": setup_intent.id, "step": "payment_pending", }) logger.info(f"Created SetupIntent {setup_intent.id} for session {session_id}") return setup_intent.client_secret, stripe_customer_id # ========================================================================= # Welcome Email # ========================================================================= def send_welcome_email( self, db: Session, user: User, vendor: Vendor, tier_code: str, language: str = "fr", ) -> None: """ Send welcome email to new vendor. Args: db: Database session user: User who signed up vendor: Vendor that was created tier_code: Selected tier code language: Language for email (default: French) """ try: # Get tier name tier_enum = TierCode(tier_code) tier_name = TIER_LIMITS.get(tier_enum, {}).get("name", tier_code.title()) # Build login URL login_url = f"https://{settings.platform_domain}/vendor/{vendor.vendor_code}/dashboard" email_service = EmailService(db) email_service.send_template( template_code="signup_welcome", language=language, to_email=user.email, to_name=f"{user.first_name} {user.last_name}", variables={ "first_name": user.first_name, "company_name": vendor.name, "email": user.email, "vendor_code": vendor.vendor_code, "login_url": login_url, "trial_days": settings.stripe_trial_days, "tier_name": tier_name, }, vendor_id=vendor.id, user_id=user.id, related_type="signup", ) logger.info(f"Welcome email sent to {user.email}") except Exception as e: # Log error but don't fail signup logger.error(f"Failed to send welcome email to {user.email}: {e}") # ========================================================================= # Signup Completion # ========================================================================= def complete_signup( self, db: Session, session_id: str, setup_intent_id: str, ) -> SignupCompletionResult: """ Complete signup after card collection. Args: db: Database session session_id: Signup session ID setup_intent_id: Stripe SetupIntent ID Returns: SignupCompletionResult Raises: EntityNotFoundException: If session not found ValidationException: If signup incomplete or payment failed """ session = self.get_session_or_raise(session_id) vendor_id = session.get("vendor_id") stripe_customer_id = session.get("stripe_customer_id") if not vendor_id or not stripe_customer_id: raise ValidationException( message="Incomplete signup. Please start again.", field="session_id", ) # Retrieve SetupIntent to get payment method setup_intent = stripe_service.get_setup_intent(setup_intent_id) if setup_intent.status != "succeeded": raise ValidationException( message="Card setup not completed. Please try again.", field="setup_intent_id", ) payment_method_id = setup_intent.payment_method # Attach payment method to customer stripe_service.attach_payment_method_to_customer( customer_id=stripe_customer_id, payment_method_id=payment_method_id, set_as_default=True, ) # Update subscription record subscription = ( db.query(VendorSubscription) .filter(VendorSubscription.vendor_id == vendor_id) .first() ) if subscription: subscription.card_collected_at = datetime.now(UTC) subscription.stripe_payment_method_id = payment_method_id db.commit() # noqa: SVC-006 - Finalize signup needs commit # Get vendor info vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() vendor_code = vendor.vendor_code if vendor else session.get("vendor_code") trial_ends_at = ( subscription.trial_ends_at if subscription else datetime.now(UTC) + timedelta(days=30) ) # Get user for welcome email user_id = session.get("user_id") user = db.query(User).filter(User.id == user_id).first() if user_id else None # Send welcome email if user and vendor: tier_code = session.get("tier_code", TierCode.ESSENTIAL.value) self.send_welcome_email(db, user, vendor, tier_code) # Clean up session self.delete_session(session_id) 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}/onboarding", trial_ends_at=trial_ends_at.isoformat(), ) # Singleton instance platform_signup_service = PlatformSignupService()