# app/api/v1/platform/signup.py """ Platform signup API endpoints. Handles the multi-step signup flow: 1. Start signup (select tier) 2. Claim Letzshop vendor (optional) 3. Create account 4. Setup payment (collect card via SetupIntent) 5. Complete signup (create subscription with trial) """ import logging import secrets from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, EmailStr from sqlalchemy.orm import Session from app.core.config import settings from app.core.database import get_db from app.services.stripe_service import stripe_service from models.database.subscription import ( SubscriptionStatus, TierCode, VendorSubscription, ) from models.database.vendor import Vendor, VendorUser, VendorUserType router = APIRouter() logger = logging.getLogger(__name__) # ============================================================================= # In-memory signup session storage (for simplicity) # 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) def get_session(session_id: str) -> dict | None: """Get a signup session by ID.""" return _signup_sessions.get(session_id) def save_session(session_id: str, data: dict) -> None: """Save signup session data.""" _signup_sessions[session_id] = { **data, "updated_at": datetime.now(UTC).isoformat(), } def delete_session(session_id: str) -> None: """Delete a signup session.""" _signup_sessions.pop(session_id, None) # ============================================================================= # Request/Response Schemas # ============================================================================= class SignupStartRequest(BaseModel): """Start signup - select tier.""" tier_code: str is_annual: bool = False class SignupStartResponse(BaseModel): """Response from signup start.""" session_id: str tier_code: str is_annual: bool class ClaimVendorRequest(BaseModel): """Claim Letzshop vendor.""" session_id: str letzshop_slug: str letzshop_vendor_id: str | None = None class ClaimVendorResponse(BaseModel): """Response from vendor claim.""" session_id: str letzshop_slug: str vendor_name: str | None class CreateAccountRequest(BaseModel): """Create account.""" session_id: str email: EmailStr password: str first_name: str last_name: str company_name: str phone: str | None = None class CreateAccountResponse(BaseModel): """Response from account creation.""" session_id: str user_id: int vendor_id: int stripe_customer_id: str class SetupPaymentRequest(BaseModel): """Request payment setup.""" session_id: str class SetupPaymentResponse(BaseModel): """Response with Stripe SetupIntent client secret.""" session_id: str client_secret: str stripe_customer_id: str class CompleteSignupRequest(BaseModel): """Complete signup after card collection.""" session_id: str setup_intent_id: str class CompleteSignupResponse(BaseModel): """Response from signup completion.""" success: bool vendor_code: str vendor_id: int redirect_url: str trial_ends_at: str # ============================================================================= # Endpoints # ============================================================================= @router.post("/signup/start", response_model=SignupStartResponse) async def start_signup( request: SignupStartRequest, db: Session = Depends(get_db), ) -> SignupStartResponse: """ Start the signup process. Step 1: User selects a tier and billing period. Creates a signup session to track the flow. """ # Validate tier code try: tier = TierCode(request.tier_code) except ValueError: raise HTTPException( status_code=400, detail=f"Invalid tier code: {request.tier_code}", ) # Create session session_id = create_session_id() save_session(session_id, { "step": "tier_selected", "tier_code": tier.value, "is_annual": request.is_annual, "created_at": datetime.now(UTC).isoformat(), }) logger.info(f"Started signup session {session_id} for tier {tier.value}") return SignupStartResponse( session_id=session_id, tier_code=tier.value, is_annual=request.is_annual, ) @router.post("/signup/claim-vendor", response_model=ClaimVendorResponse) async def claim_letzshop_vendor( request: ClaimVendorRequest, db: Session = Depends(get_db), ) -> ClaimVendorResponse: """ Claim a Letzshop vendor. Step 2 (optional): User claims their Letzshop shop. This pre-fills vendor info during account creation. """ session = get_session(request.session_id) if not session: raise HTTPException(status_code=404, detail="Signup session not found") # Check if vendor is already claimed existing = db.query(Vendor).filter( Vendor.letzshop_vendor_slug == request.letzshop_slug, Vendor.is_active == True, ).first() if existing: raise HTTPException( status_code=400, detail="This Letzshop vendor is already claimed", ) # Update session with vendor info session["letzshop_slug"] = request.letzshop_slug session["letzshop_vendor_id"] = request.letzshop_vendor_id session["step"] = "vendor_claimed" # TODO: Fetch actual vendor name from Letzshop API vendor_name = request.letzshop_slug.replace("-", " ").title() session["vendor_name"] = vendor_name save_session(request.session_id, session) logger.info(f"Claimed vendor {request.letzshop_slug} for session {request.session_id}") return ClaimVendorResponse( session_id=request.session_id, letzshop_slug=request.letzshop_slug, vendor_name=vendor_name, ) @router.post("/signup/create-account", response_model=CreateAccountResponse) async def create_account( request: CreateAccountRequest, db: Session = Depends(get_db), ) -> CreateAccountResponse: """ Create user and vendor accounts. Step 3: User provides account details. Creates User, Company, Vendor, and Stripe Customer. """ session = get_session(request.session_id) if not session: raise HTTPException(status_code=404, detail="Signup session not found") # Check if email already exists from models.database.user import User existing_user = db.query(User).filter(User.email == request.email).first() if existing_user: raise HTTPException( status_code=400, detail="An account with this email already exists", ) try: # Create User first (needed for Company owner) from middleware.auth import AuthManager auth_manager = AuthManager() # Generate username from email username = request.email.split("@")[0] base_username = username counter = 1 while db.query(User).filter(User.username == username).first(): username = f"{base_username}_{counter}" counter += 1 user = User( email=request.email, username=username, hashed_password=auth_manager.hash_password(request.password), first_name=request.first_name, last_name=request.last_name, role="vendor", is_active=True, ) db.add(user) db.flush() # Create Company (with owner) from models.database.company import Company company = Company( name=request.company_name, owner_user_id=user.id, contact_email=request.email, contact_phone=request.phone, ) db.add(company) db.flush() # Generate vendor code vendor_code = request.company_name.upper().replace(" ", "_")[:20] # Ensure unique 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 # Generate subdomain subdomain = request.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 # Create Vendor vendor = Vendor( company_id=company.id, vendor_code=vendor_code, subdomain=subdomain, name=request.company_name, contact_email=request.email, contact_phone=request.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 Stripe Customer stripe_customer_id = stripe_service.create_customer( vendor=vendor, email=request.email, name=f"{request.first_name} {request.last_name}", metadata={ "company_name": request.company_name, "tier": session.get("tier_code"), }, ) # Create VendorSubscription (in trial status, without Stripe subscription yet) 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() # Update session session["user_id"] = user.id session["vendor_id"] = vendor.id session["vendor_code"] = vendor_code session["stripe_customer_id"] = stripe_customer_id session["step"] = "account_created" save_session(request.session_id, session) logger.info( f"Created account for {request.email}: " f"user_id={user.id}, vendor_id={vendor.id}" ) return CreateAccountResponse( session_id=request.session_id, user_id=user.id, vendor_id=vendor.id, stripe_customer_id=stripe_customer_id, ) except Exception as e: db.rollback() logger.error(f"Error creating account: {e}") raise HTTPException(status_code=500, detail="Failed to create account") @router.post("/signup/setup-payment", response_model=SetupPaymentResponse) async def setup_payment( request: SetupPaymentRequest, db: Session = Depends(get_db), ) -> SetupPaymentResponse: """ Create Stripe SetupIntent for card collection. Step 4: Collect card details without charging. The card will be charged after the trial period ends. """ session = get_session(request.session_id) if not session: raise HTTPException(status_code=404, detail="Signup session not found") if "stripe_customer_id" not in session: raise HTTPException( status_code=400, detail="Account not created. Please complete step 3 first.", ) stripe_customer_id = session["stripe_customer_id"] # Create SetupIntent setup_intent = stripe_service.create_setup_intent( customer_id=stripe_customer_id, metadata={ "session_id": request.session_id, "vendor_id": str(session.get("vendor_id")), "tier": session.get("tier_code"), }, ) # Update session session["setup_intent_id"] = setup_intent.id session["step"] = "payment_pending" save_session(request.session_id, session) logger.info(f"Created SetupIntent {setup_intent.id} for session {request.session_id}") return SetupPaymentResponse( session_id=request.session_id, client_secret=setup_intent.client_secret, stripe_customer_id=stripe_customer_id, ) @router.post("/signup/complete", response_model=CompleteSignupResponse) async def complete_signup( request: CompleteSignupRequest, db: Session = Depends(get_db), ) -> CompleteSignupResponse: """ Complete signup after card collection. Step 5: Verify SetupIntent, attach payment method, create subscription. """ session = get_session(request.session_id) if not session: raise HTTPException(status_code=404, detail="Signup session not found") vendor_id = session.get("vendor_id") stripe_customer_id = session.get("stripe_customer_id") if not vendor_id or not stripe_customer_id: raise HTTPException( status_code=400, detail="Incomplete signup. Please start again.", ) try: # Retrieve SetupIntent to get payment method setup_intent = stripe_service.get_setup_intent(request.setup_intent_id) if setup_intent.status != "succeeded": raise HTTPException( status_code=400, detail="Card setup not completed. Please try again.", ) 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 with card collection time 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 # TODO: Create actual Stripe subscription with trial # For now, just mark as trial with card collected db.commit() vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() vendor_code = vendor.vendor_code if vendor else session.get("vendor_code") # Clean up session delete_session(request.session_id) trial_ends_at = subscription.trial_ends_at if subscription else datetime.now(UTC) + timedelta(days=30) logger.info(f"Completed signup for vendor {vendor_id}") return CompleteSignupResponse( success=True, vendor_code=vendor_code, vendor_id=vendor_id, redirect_url=f"/vendors/{vendor_code}/dashboard", trial_ends_at=trial_ends_at.isoformat(), ) except HTTPException: raise except Exception as e: logger.error(f"Error completing signup: {e}") raise HTTPException(status_code=500, detail="Failed to complete signup") @router.get("/signup/session/{session_id}") async def get_signup_session(session_id: str) -> dict: """ Get signup session status. Useful for resuming an incomplete signup. """ session = get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") # Return safe subset of session data return { "session_id": session_id, "step": session.get("step"), "tier_code": session.get("tier_code"), "is_annual": session.get("is_annual"), "letzshop_slug": session.get("letzshop_slug"), "vendor_name": session.get("vendor_name"), "created_at": session.get("created_at"), }