Improve onboarding

This commit is contained in:
Urtzi Alfaro
2025-12-18 13:26:32 +01:00
parent f76b3f8e6b
commit f10a2b92ea
42 changed files with 2175 additions and 984 deletions

View File

@@ -193,6 +193,7 @@ async def clone_demo_data(
plan=subscription_data.get('plan', 'professional'),
status=subscription_data.get('status', 'active'),
monthly_price=subscription_data.get('monthly_price', 299.00),
billing_cycle=subscription_data.get('billing_cycle', 'monthly'),
max_users=subscription_data.get('max_users', 10),
max_locations=subscription_data.get('max_locations', 3),
max_products=subscription_data.get('max_products', 500),
@@ -206,6 +207,18 @@ async def clone_demo_data(
subscription_data.get('next_billing_date'),
session_time,
"next_billing_date"
),
stripe_subscription_id=subscription_data.get('stripe_subscription_id'),
stripe_customer_id=subscription_data.get('stripe_customer_id'),
cancelled_at=parse_date_field(
subscription_data.get('cancelled_at'),
session_time,
"cancelled_at"
),
cancellation_effective_date=parse_date_field(
subscription_data.get('cancellation_effective_date'),
session_time,
"cancellation_effective_date"
)
)

View File

@@ -125,6 +125,26 @@ async def cancel_subscription(
await db.commit()
await db.refresh(subscription)
# CRITICAL: Invalidate subscription cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(tenant_id))
logger.info(
"Subscription cache invalidated after cancellation",
tenant_id=str(tenant_id)
)
except Exception as cache_error:
logger.error(
"Failed to invalidate subscription cache after cancellation",
tenant_id=str(tenant_id),
error=str(cache_error)
)
days_remaining = (cancellation_effective_date - datetime.now(timezone.utc)).days
logger.info(
@@ -197,6 +217,26 @@ async def reactivate_subscription(
await db.commit()
await db.refresh(subscription)
# CRITICAL: Invalidate subscription cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(tenant_id))
logger.info(
"Subscription cache invalidated after reactivation",
tenant_id=str(tenant_id)
)
except Exception as cache_error:
logger.error(
"Failed to invalidate subscription cache after reactivation",
tenant_id=str(tenant_id),
error=str(cache_error)
)
logger.info(
"subscription_reactivated",
tenant_id=str(tenant_id),

View File

@@ -6,7 +6,13 @@ from fastapi import APIRouter, Depends, HTTPException, status, Path
from typing import List, Dict, Any
from uuid import UUID
from app.schemas.tenants import TenantResponse
from app.schemas.tenants import (
TenantResponse,
ChildTenantCreate,
BulkChildTenantsCreate,
BulkChildTenantsResponse,
ChildTenantResponse
)
from app.services.tenant_service import EnhancedTenantService
from app.repositories.tenant_repository import TenantRepository
from shared.auth.decorators import get_current_user_dep
@@ -213,12 +219,248 @@ async def get_tenant_children_count(
)
@router.post(route_builder.build_base_route("bulk-children", include_tenant_prefix=False), response_model=BulkChildTenantsResponse)
@track_endpoint_metrics("bulk_create_child_tenants")
async def bulk_create_child_tenants(
request: BulkChildTenantsCreate,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""
Bulk create child tenants for enterprise onboarding.
This endpoint creates multiple child tenants (outlets/branches) for an enterprise parent tenant
and establishes the parent-child relationship. It's designed for use during the onboarding flow
when an enterprise customer registers their network of locations.
Features:
- Creates child tenants with proper hierarchy
- Inherits subscription from parent
- Optionally configures distribution routes
- Returns detailed success/failure information
"""
try:
logger.info(
"Bulk child tenant creation request received",
parent_tenant_id=request.parent_tenant_id,
child_count=len(request.child_tenants),
user_id=current_user.get("user_id")
)
# Verify parent tenant exists and user has access
async with tenant_service.database_manager.get_session() as session:
from app.models.tenants import Tenant
tenant_repo = TenantRepository(Tenant, session)
parent_tenant = await tenant_repo.get_by_id(request.parent_tenant_id)
if not parent_tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent tenant not found"
)
# Verify user has access to parent tenant (owners/admins only)
access_info = await tenant_service.verify_user_access(
current_user["user_id"],
request.parent_tenant_id
)
if not access_info.has_access or access_info.role not in ["owner", "admin"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only tenant owners/admins can create child tenants"
)
# Verify parent is enterprise tier
parent_subscription_tier = await tenant_service.get_subscription_tier(request.parent_tenant_id)
if parent_subscription_tier != "enterprise":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only enterprise tier tenants can have child tenants"
)
# Update parent tenant type if it's still standalone
if parent_tenant.tenant_type == "standalone":
parent_tenant.tenant_type = "parent"
parent_tenant.hierarchy_path = str(parent_tenant.id)
await session.commit()
await session.refresh(parent_tenant)
# Create child tenants
created_tenants = []
failed_tenants = []
for child_data in request.child_tenants:
try:
# Create child tenant
child_tenant = Tenant(
name=child_data.name,
subdomain=None, # Child tenants typically don't have subdomains
business_type=parent_tenant.business_type,
business_model="retail_bakery", # Child outlets are typically retail
address=child_data.address,
city=child_data.city,
postal_code=child_data.postal_code,
latitude=child_data.latitude,
longitude=child_data.longitude,
phone=child_data.phone or parent_tenant.phone,
email=child_data.email or parent_tenant.email,
timezone=parent_tenant.timezone,
owner_id=parent_tenant.owner_id,
parent_tenant_id=parent_tenant.id,
tenant_type="child",
hierarchy_path=f"{parent_tenant.hierarchy_path}/{str(parent_tenant.id)}",
is_active=True,
is_demo=parent_tenant.is_demo,
demo_session_id=parent_tenant.demo_session_id,
demo_expires_at=parent_tenant.demo_expires_at
)
session.add(child_tenant)
await session.flush() # Get the ID without committing
# Create TenantLocation record for the child with location_code
from app.models.tenant_location import TenantLocation
location = TenantLocation(
tenant_id=child_tenant.id,
name=child_data.name,
location_code=child_data.location_code,
city=child_data.city,
zone=child_data.zone,
address=child_data.address,
postal_code=child_data.postal_code,
latitude=child_data.latitude,
longitude=child_data.longitude,
status="ACTIVE",
is_primary=True,
enterprise_location=True,
location_type="retail"
)
session.add(location)
# Inherit subscription from parent
from app.models.tenants import Subscription
parent_subscription = await session.execute(
session.query(Subscription).filter(
Subscription.tenant_id == parent_tenant.id,
Subscription.status == "active"
).statement
)
parent_sub = parent_subscription.scalar_one_or_none()
if parent_sub:
child_subscription = Subscription(
tenant_id=child_tenant.id,
plan=parent_sub.plan,
status="active",
billing_cycle=parent_sub.billing_cycle,
price=0, # Child tenants don't pay separately
trial_ends_at=parent_sub.trial_ends_at
)
session.add(child_subscription)
await session.commit()
await session.refresh(child_tenant)
await session.refresh(location)
# Build response
created_tenants.append(ChildTenantResponse(
id=str(child_tenant.id),
name=child_tenant.name,
subdomain=child_tenant.subdomain,
business_type=child_tenant.business_type,
business_model=child_tenant.business_model,
tenant_type=child_tenant.tenant_type,
parent_tenant_id=str(child_tenant.parent_tenant_id),
address=child_tenant.address,
city=child_tenant.city,
postal_code=child_tenant.postal_code,
phone=child_tenant.phone,
is_active=child_tenant.is_active,
subscription_plan="enterprise",
ml_model_trained=child_tenant.ml_model_trained,
last_training_date=child_tenant.last_training_date,
owner_id=str(child_tenant.owner_id),
created_at=child_tenant.created_at,
location_code=location.location_code,
zone=location.zone,
hierarchy_path=child_tenant.hierarchy_path
))
logger.info(
"Child tenant created successfully",
child_tenant_id=str(child_tenant.id),
child_name=child_tenant.name,
location_code=child_data.location_code
)
except Exception as child_error:
logger.error(
"Failed to create child tenant",
child_name=child_data.name,
error=str(child_error)
)
failed_tenants.append({
"name": child_data.name,
"location_code": child_data.location_code,
"error": str(child_error)
})
await session.rollback()
# TODO: Configure distribution routes if requested
distribution_configured = False
if request.auto_configure_distribution and len(created_tenants) > 0:
try:
# This would call the distribution service to set up routes
# For now, we'll skip this and just log
logger.info(
"Distribution route configuration requested",
parent_tenant_id=request.parent_tenant_id,
child_count=len(created_tenants)
)
# distribution_configured = await configure_distribution_routes(...)
except Exception as dist_error:
logger.warning(
"Failed to configure distribution routes",
error=str(dist_error)
)
logger.info(
"Bulk child tenant creation completed",
parent_tenant_id=request.parent_tenant_id,
created_count=len(created_tenants),
failed_count=len(failed_tenants)
)
return BulkChildTenantsResponse(
parent_tenant_id=request.parent_tenant_id,
created_count=len(created_tenants),
failed_count=len(failed_tenants),
created_tenants=created_tenants,
failed_tenants=failed_tenants,
distribution_configured=distribution_configured
)
except HTTPException:
raise
except Exception as e:
logger.error(
"Bulk child tenant creation failed",
parent_tenant_id=request.parent_tenant_id,
user_id=current_user.get("user_id"),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Bulk child tenant creation failed: {str(e)}"
)
# Register the router in the main app
def register_hierarchy_routes(app):
"""Register hierarchy routes with the main application"""
from shared.routing.route_builder import RouteBuilder
route_builder = RouteBuilder("tenants")
# Include the hierarchy routes with proper tenant prefix
app.include_router(
router,

View File

@@ -48,6 +48,31 @@ async def add_team_member_with_user_creation(
In production, this will be replaced with an invitation-based flow.
"""
try:
# CRITICAL: Check subscription limit before adding user
from app.services.subscription_limit_service import SubscriptionLimitService
limit_service = SubscriptionLimitService()
limit_check = await limit_service.can_add_user(str(tenant_id))
if not limit_check.get('can_add', False):
logger.warning(
"User limit exceeded",
tenant_id=str(tenant_id),
current=limit_check.get('current_count'),
max=limit_check.get('max_allowed'),
reason=limit_check.get('reason')
)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"error": "user_limit_exceeded",
"message": limit_check.get('reason', 'User limit exceeded'),
"current_count": limit_check.get('current_count'),
"max_allowed": limit_check.get('max_allowed'),
"upgrade_required": True
}
)
user_id_to_add = member_data.user_id
# If create_user is True, create the user first via auth service
@@ -151,12 +176,45 @@ async def add_team_member(
"""Add an existing team member to tenant (legacy endpoint)"""
try:
# CRITICAL: Check subscription limit before adding user
from app.services.subscription_limit_service import SubscriptionLimitService
limit_service = SubscriptionLimitService()
limit_check = await limit_service.can_add_user(str(tenant_id))
if not limit_check.get('can_add', False):
logger.warning(
"User limit exceeded",
tenant_id=str(tenant_id),
current=limit_check.get('current_count'),
max=limit_check.get('max_allowed'),
reason=limit_check.get('reason')
)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"error": "user_limit_exceeded",
"message": limit_check.get('reason', 'User limit exceeded'),
"current_count": limit_check.get('current_count'),
"max_allowed": limit_check.get('max_allowed'),
"upgrade_required": True
}
)
result = await tenant_service.add_team_member(
str(tenant_id),
user_id,
role,
current_user["user_id"]
)
logger.info(
"Team member added successfully",
tenant_id=str(tenant_id),
user_id=user_id,
role=role
)
return result
except HTTPException:

View File

@@ -141,6 +141,44 @@ async def register_bakery(
current_user["user_id"]
)
# CRITICAL: Create default subscription for new tenant
try:
from app.repositories.subscription_repository import SubscriptionRepository
from app.models.tenants import Subscription
from datetime import datetime, timedelta, timezone
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
async with database_manager.get_session() as session:
subscription_repo = SubscriptionRepository(Subscription, session)
# Create starter subscription with 14-day trial
trial_end_date = datetime.now(timezone.utc) + timedelta(days=14)
next_billing_date = trial_end_date
await subscription_repo.create_subscription(
tenant_id=str(result.id),
plan="starter",
status="active",
billing_cycle="monthly",
next_billing_date=next_billing_date,
trial_ends_at=trial_end_date
)
await session.commit()
logger.info(
"Default subscription created for new tenant",
tenant_id=str(result.id),
plan="starter",
trial_days=14
)
except Exception as subscription_error:
logger.error(
"Failed to create default subscription for tenant",
tenant_id=str(result.id),
error=str(subscription_error)
)
# Don't fail tenant creation if subscription creation fails
# If coupon was validated, redeem it now with actual tenant_id
if coupon_validation and coupon_validation["valid"]:
from app.core.config import settings
@@ -785,6 +823,48 @@ async def can_add_user(
detail="Failed to check user limits"
)
@router.get(route_builder.build_base_route("{tenant_id}/recipes/can-add", include_tenant_prefix=False))
async def can_add_recipe(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
):
"""Check if tenant can add another recipe"""
try:
result = await limit_service.can_add_recipe(str(tenant_id))
return result
except Exception as e:
logger.error("Failed to check recipe limits",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to check recipe limits"
)
@router.get(route_builder.build_base_route("{tenant_id}/suppliers/can-add", include_tenant_prefix=False))
async def can_add_supplier(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
):
"""Check if tenant can add another supplier"""
try:
result = await limit_service.can_add_supplier(str(tenant_id))
return result
except Exception as e:
logger.error("Failed to check supplier limits",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to check supplier limits"
)
@router.get(route_builder.build_base_route("subscriptions/{tenant_id}/features/{feature}", include_tenant_prefix=False))
async def has_feature(
tenant_id: UUID = Path(..., description="Tenant ID"),

View File

@@ -197,8 +197,102 @@ class TenantStatsResponse(BaseModel):
last_training_date: Optional[datetime]
subscription_plan: str
subscription_status: str
@field_validator('tenant_id', mode='before')
# ============================================================================
# ENTERPRISE CHILD TENANT SCHEMAS
# ============================================================================
class ChildTenantCreate(BaseModel):
"""Schema for creating a child tenant in enterprise hierarchy"""
name: str = Field(..., min_length=2, max_length=200, description="Child tenant name (e.g., 'Madrid - Salamanca')")
city: str = Field(..., min_length=2, max_length=100, description="City where the outlet is located")
zone: Optional[str] = Field(None, max_length=100, description="Zone or neighborhood")
address: str = Field(..., min_length=10, max_length=500, description="Full address of the outlet")
postal_code: str = Field(..., pattern=r"^\d{5}$", description="5-digit postal code")
location_code: str = Field(..., min_length=1, max_length=10, description="Short location code (e.g., MAD, BCN)")
# Optional coordinates (can be geocoded from address if not provided)
latitude: Optional[float] = Field(None, ge=-90, le=90, description="Latitude coordinate")
longitude: Optional[float] = Field(None, ge=-180, le=180, description="Longitude coordinate")
# Optional contact info (inherits from parent if not provided)
phone: Optional[str] = Field(None, min_length=9, max_length=20, description="Contact phone")
email: Optional[str] = Field(None, description="Contact email")
@field_validator('location_code')
@classmethod
def validate_location_code(cls, v):
"""Ensure location code is uppercase and alphanumeric"""
if not v.replace('-', '').replace('_', '').isalnum():
raise ValueError('Location code must be alphanumeric (with optional hyphens/underscores)')
return v.upper()
@field_validator('phone')
@classmethod
def validate_phone(cls, v):
"""Validate Spanish phone number if provided"""
if v is None:
return v
phone = re.sub(r'[\s\-\(\)]', '', v)
patterns = [
r'^(\+34|0034|34)?[6789]\d{8}$', # Mobile
r'^(\+34|0034|34)?9\d{8}$', # Landline
]
if not any(re.match(pattern, phone) for pattern in patterns):
raise ValueError('Invalid Spanish phone number')
return v
class BulkChildTenantsCreate(BaseModel):
"""Schema for bulk creating child tenants during onboarding"""
parent_tenant_id: str = Field(..., description="ID of the parent (central baker) tenant")
child_tenants: List[ChildTenantCreate] = Field(
...,
min_length=1,
max_length=50,
description="List of child tenants to create (1-50)"
)
# Optional: Auto-configure distribution routes
auto_configure_distribution: bool = Field(
True,
description="Whether to automatically set up distribution routes between parent and children"
)
@field_validator('child_tenants')
@classmethod
def validate_unique_location_codes(cls, v):
"""Ensure all location codes are unique within the batch"""
location_codes = [ct.location_code for ct in v]
if len(location_codes) != len(set(location_codes)):
raise ValueError('Location codes must be unique within the batch')
return v
class ChildTenantResponse(TenantResponse):
"""Response schema for child tenant - extends TenantResponse"""
location_code: Optional[str] = None
zone: Optional[str] = None
hierarchy_path: Optional[str] = None
class Config:
from_attributes = True
class BulkChildTenantsResponse(BaseModel):
"""Response schema for bulk child tenant creation"""
parent_tenant_id: str
created_count: int
failed_count: int
created_tenants: List[ChildTenantResponse]
failed_tenants: List[Dict[str, Any]] = Field(
default_factory=list,
description="List of failed tenants with error details"
)
distribution_configured: bool = False
@field_validator('parent_tenant_id', mode='before')
@classmethod
def convert_uuid_to_string(cls, v):
"""Convert UUID objects to strings for JSON serialization"""

View File

@@ -155,20 +155,20 @@ class SubscriptionLimitService:
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
# Get subscription limits
subscription = await self.subscription_repo.get_active_subscription(tenant_id)
if not subscription:
return {"can_add": False, "reason": "No active subscription"}
# Check if unlimited users (-1)
if subscription.max_users == -1:
return {"can_add": True, "reason": "Unlimited users allowed"}
# Count current active members
members = await self.member_repo.get_tenant_members(tenant_id, active_only=True)
current_users = len(members)
can_add = current_users < subscription.max_users
return {
"can_add": can_add,
@@ -176,12 +176,80 @@ class SubscriptionLimitService:
"max_allowed": subscription.max_users,
"reason": "Within limits" if can_add else f"Maximum {subscription.max_users} users allowed for {subscription.plan} plan"
}
except Exception as e:
logger.error("Failed to check user limits",
tenant_id=tenant_id,
error=str(e))
return {"can_add": False, "reason": "Error checking limits"}
async def can_add_recipe(self, tenant_id: str) -> Dict[str, Any]:
"""Check if tenant can add another recipe"""
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
subscription = await self.subscription_repo.get_active_subscription(tenant_id)
if not subscription:
return {"can_add": False, "reason": "No active subscription"}
# Get recipe limit from plan
recipes_limit = await self._get_limit_from_plan(subscription.plan, 'recipes')
# Check if unlimited (-1 or None)
if recipes_limit is None or recipes_limit == -1:
return {"can_add": True, "reason": "Unlimited recipes allowed"}
# Count current recipes from recipes service
current_recipes = await self._get_recipe_count(tenant_id)
can_add = current_recipes < recipes_limit
return {
"can_add": can_add,
"current_count": current_recipes,
"max_allowed": recipes_limit,
"reason": "Within limits" if can_add else f"Maximum {recipes_limit} recipes allowed for {subscription.plan} plan"
}
except Exception as e:
logger.error("Failed to check recipe limits",
tenant_id=tenant_id,
error=str(e))
return {"can_add": False, "reason": "Error checking limits"}
async def can_add_supplier(self, tenant_id: str) -> Dict[str, Any]:
"""Check if tenant can add another supplier"""
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
subscription = await self.subscription_repo.get_active_subscription(tenant_id)
if not subscription:
return {"can_add": False, "reason": "No active subscription"}
# Get supplier limit from plan
suppliers_limit = await self._get_limit_from_plan(subscription.plan, 'suppliers')
# Check if unlimited (-1 or None)
if suppliers_limit is None or suppliers_limit == -1:
return {"can_add": True, "reason": "Unlimited suppliers allowed"}
# Count current suppliers from suppliers service
current_suppliers = await self._get_supplier_count(tenant_id)
can_add = current_suppliers < suppliers_limit
return {
"can_add": can_add,
"current_count": current_suppliers,
"max_allowed": suppliers_limit,
"reason": "Within limits" if can_add else f"Maximum {suppliers_limit} suppliers allowed for {subscription.plan} plan"
}
except Exception as e:
logger.error("Failed to check supplier limits",
tenant_id=tenant_id,
error=str(e))
return {"can_add": False, "reason": "Error checking limits"}
async def has_feature(self, tenant_id: str, feature: str) -> Dict[str, Any]:
"""Check if tenant has access to a specific feature"""