refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -18,6 +18,17 @@ from app.modules.marketplace.services.marketplace_product_service import (
MarketplaceProductService,
marketplace_product_service,
)
from app.modules.marketplace.services.onboarding_service import (
OnboardingService,
get_onboarding_service,
)
from app.modules.marketplace.services.platform_signup_service import (
PlatformSignupService,
platform_signup_service,
SignupSessionData,
AccountCreationResult,
SignupCompletionResult,
)
# Letzshop submodule services
from app.modules.marketplace.services.letzshop import (
@@ -42,6 +53,15 @@ __all__ = [
# Product service
"MarketplaceProductService",
"marketplace_product_service",
# Onboarding service
"OnboardingService",
"get_onboarding_service",
# Platform signup service
"PlatformSignupService",
"platform_signup_service",
"SignupSessionData",
"AccountCreationResult",
"SignupCompletionResult",
# Letzshop services
"LetzshopClient",
"LetzshopClientError",

View File

@@ -14,8 +14,8 @@ from typing import Any, Callable
from sqlalchemy import String, and_, func, or_
from sqlalchemy.orm import Session
from app.services.order_service import order_service as unified_order_service
from app.services.subscription_service import subscription_service
from app.modules.orders.services.order_service import order_service as unified_order_service
from app.modules.billing.services.subscription_service import subscription_service
from app.modules.marketplace.models import (
LetzshopFulfillmentQueue,
LetzshopHistoricalImportJob,

View File

@@ -436,7 +436,7 @@ class LetzshopVendorSyncService:
from sqlalchemy import func
from app.services.admin_service import admin_service
from app.modules.tenancy.services.admin_service import admin_service
from models.database.company import Company
from models.database.vendor import Vendor
from models.schema.vendor import VendorCreate

View File

@@ -3,10 +3,10 @@ import logging
from sqlalchemy.orm import Session
from app.exceptions import (
from app.exceptions import ValidationException
from app.modules.marketplace.exceptions import (
ImportJobNotFoundException,
ImportJobNotOwnedException,
ValidationException,
)
from app.modules.marketplace.models import (
MarketplaceImportError,
@@ -115,7 +115,7 @@ class MarketplaceImportJobService:
ImportJobNotFoundException: If job not found
UnauthorizedVendorAccessException: If job doesn't belong to vendor
"""
from app.exceptions import UnauthorizedVendorAccessException
from app.modules.tenancy.exceptions import UnauthorizedVendorAccessException
try:
job = (

View File

@@ -22,12 +22,12 @@ from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from app.exceptions import (
from app.exceptions import ValidationException
from app.modules.marketplace.exceptions import (
InvalidMarketplaceProductDataException,
MarketplaceProductAlreadyExistsException,
MarketplaceProductNotFoundException,
MarketplaceProductValidationException,
ValidationException,
)
from app.utils.data_processing import GTINProcessor, PriceProcessor
from app.modules.inventory.models import Inventory
@@ -865,7 +865,7 @@ class MarketplaceProductService:
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
from app.exceptions import VendorNotFoundException
from app.modules.tenancy.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
@@ -880,7 +880,7 @@ class MarketplaceProductService:
raise MarketplaceProductNotFoundException("No marketplace products found")
# Check product limit from subscription
from app.services.subscription_service import subscription_service
from app.modules.billing.services.subscription_service import subscription_service
from sqlalchemy import func
current_products = (
@@ -998,7 +998,7 @@ class MarketplaceProductService:
# Auto-match pending order item exceptions
# Collect GTINs and their product IDs from newly copied products
from app.services.order_item_exception_service import (
from app.modules.orders.services.order_item_exception_service import (
order_item_exception_service,
)

View File

@@ -0,0 +1,664 @@
# app/modules/marketplace/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.modules.tenancy.exceptions import VendorNotFoundException
from app.modules.marketplace.exceptions import (
OnboardingCsvUrlRequiredException,
OnboardingNotFoundException,
OnboardingStepOrderException,
OnboardingSyncJobNotFoundException,
OnboardingSyncNotCompleteException,
)
from app.modules.marketplace.services.letzshop import (
LetzshopCredentialsService,
LetzshopOrderService,
)
from app.modules.marketplace.models import (
OnboardingStatus,
OnboardingStep,
VendorOnboarding,
)
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
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 OnboardingNotFoundException."""
onboarding = self.get_onboarding(vendor_id)
if onboarding is None:
raise OnboardingNotFoundException(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.
"""
# Check vendor exists BEFORE creating onboarding record (FK constraint)
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(vendor_id)
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)
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 OnboardingStepOrderException(
current_step=onboarding.current_step,
required_step=OnboardingStep.COMPANY_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(
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 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 vendor settings
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(vendor_id)
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 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(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":
# 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,
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 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(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)

View File

@@ -0,0 +1,649 @@
# app/modules/marketplace/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.modules.messaging.services.email_service import EmailService
from app.modules.marketplace.services.onboarding_service import OnboardingService
from app.modules.billing.services.stripe_service import stripe_service
from middleware.auth import AuthManager
from models.database.company import Company
from app.modules.billing.models 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
access_token: str | None = None # JWT token for automatic login
# =============================================================================
# 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 and token generation
user_id = session.get("user_id")
user = db.query(User).filter(User.id == user_id).first() if user_id else None
# Generate access token for automatic login after signup
access_token = None
if user and vendor:
# Create vendor-scoped JWT token (user is owner since they just signed up)
token_data = self.auth_manager.create_access_token(
user=user,
vendor_id=vendor.id,
vendor_code=vendor.vendor_code,
vendor_role="Owner", # New signup is always the owner
)
access_token = token_data["access_token"]
logger.info(f"Generated access token for new vendor user {user.email}")
# 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(),
access_token=access_token,
)
# Singleton instance
platform_signup_service = PlatformSignupService()