Improve onboarding
This commit is contained in:
@@ -210,12 +210,11 @@ CREATE TABLE user_onboarding_summary (
|
||||
8. `product-categorization` - Advanced categorization (optional)
|
||||
9. `suppliers-setup` - Suppliers configuration
|
||||
10. `recipes-setup` - Production recipes (optional)
|
||||
11. `production-processes` - Finishing processes (optional)
|
||||
12. `quality-setup` - Quality standards (optional)
|
||||
13. `team-setup` - Team members (optional)
|
||||
14. `ml-training` - AI model training (requires POI detection)
|
||||
15. `setup-review` - Review all configuration
|
||||
16. `completion` - Onboarding completed
|
||||
11. `quality-setup` - Quality standards (optional)
|
||||
12. `team-setup` - Team members (optional)
|
||||
13. `ml-training` - AI model training (requires POI detection)
|
||||
14. `setup-review` - Review all configuration
|
||||
15. `completion` - Onboarding completed
|
||||
|
||||
**login_attempts**
|
||||
```sql
|
||||
|
||||
@@ -44,12 +44,15 @@ ONBOARDING_STEPS = [
|
||||
"user_registered", # Auto-completed: User account created
|
||||
|
||||
# Phase 1: Discovery
|
||||
"bakery-type-selection", # Choose bakery type: production/retail/mixed
|
||||
"bakery-type-selection", # Choose bakery type: production/retail/mixed (skipped for enterprise)
|
||||
|
||||
# Phase 2: Core Setup
|
||||
"setup", # Basic bakery setup and tenant creation
|
||||
# NOTE: POI detection now happens automatically in background during tenant registration
|
||||
|
||||
# Phase 2-Enterprise: Child Tenants Setup (enterprise tier only)
|
||||
"child-tenants-setup", # Configure child tenants/branches for enterprise tier
|
||||
|
||||
# Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
|
||||
"upload-sales-data", # File upload, validation, and AI classification
|
||||
"inventory-review", # Review and confirm AI-detected products with type selection
|
||||
@@ -63,7 +66,6 @@ ONBOARDING_STEPS = [
|
||||
|
||||
# Phase 3: Advanced Configuration (all optional)
|
||||
"recipes-setup", # Production recipes (conditional: production/mixed bakery)
|
||||
"production-processes", # Finishing processes (conditional: retail/mixed bakery)
|
||||
"quality-setup", # Quality standards and templates
|
||||
"team-setup", # Team members and permissions
|
||||
|
||||
@@ -79,10 +81,14 @@ STEP_DEPENDENCIES = {
|
||||
# Discovery phase
|
||||
"bakery-type-selection": ["user_registered"],
|
||||
|
||||
# Core setup - no longer depends on data-source-choice (removed)
|
||||
# Core setup - NOTE: bakery-type-selection dependency is conditionally required
|
||||
# Enterprise users skip bakery-type-selection, so setup only requires user_registered for them
|
||||
"setup": ["user_registered", "bakery-type-selection"],
|
||||
# NOTE: POI detection removed from steps - now happens automatically in background
|
||||
|
||||
# Enterprise child tenants setup - requires setup (parent tenant) to be completed first
|
||||
"child-tenants-setup": ["user_registered", "setup"],
|
||||
|
||||
# AI-Assisted Inventory Setup - REFACTORED into 3 sequential steps
|
||||
"upload-sales-data": ["user_registered", "setup"],
|
||||
"inventory-review": ["user_registered", "setup", "upload-sales-data"],
|
||||
@@ -96,7 +102,6 @@ STEP_DEPENDENCIES = {
|
||||
|
||||
# Advanced configuration (optional, minimal dependencies)
|
||||
"recipes-setup": ["user_registered", "setup"],
|
||||
"production-processes": ["user_registered", "setup"],
|
||||
"quality-setup": ["user_registered", "setup"],
|
||||
"team-setup": ["user_registered", "setup"],
|
||||
|
||||
@@ -270,20 +275,41 @@ class OnboardingService:
|
||||
|
||||
async def _can_complete_step(self, user_id: str, step_name: str) -> bool:
|
||||
"""Check if user can complete a specific step"""
|
||||
|
||||
|
||||
# Get required dependencies for this step
|
||||
required_steps = STEP_DEPENDENCIES.get(step_name, [])
|
||||
|
||||
required_steps = STEP_DEPENDENCIES.get(step_name, []).copy() # Copy to avoid modifying original
|
||||
|
||||
if not required_steps:
|
||||
return True # No dependencies
|
||||
|
||||
|
||||
# Check if all required steps are completed
|
||||
user_progress_data = await self._get_user_onboarding_data(user_id)
|
||||
|
||||
|
||||
# SPECIAL HANDLING FOR ENTERPRISE ONBOARDING
|
||||
# Enterprise users skip bakery-type-selection step, so don't require it for setup
|
||||
if step_name == "setup" and "bakery-type-selection" in required_steps:
|
||||
# Check if user's tenant has enterprise subscription tier
|
||||
# We do this by checking if the user has any data indicating enterprise tier
|
||||
# This could be stored in user_registered step data or we can infer from context
|
||||
user_registered_data = user_progress_data.get("user_registered", {}).get("data", {})
|
||||
subscription_tier = user_registered_data.get("subscription_tier")
|
||||
|
||||
if subscription_tier == "enterprise":
|
||||
# Enterprise users don't need bakery-type-selection
|
||||
logger.info(f"Enterprise user {user_id}: Skipping bakery-type-selection requirement for setup step")
|
||||
required_steps.remove("bakery-type-selection")
|
||||
elif not user_progress_data.get("bakery-type-selection", {}).get("completed", False):
|
||||
# Non-enterprise user hasn't completed bakery-type-selection
|
||||
# But allow setup anyway if user_registered is complete (frontend will handle it)
|
||||
# This is a fallback for when subscription_tier is not stored in user_registered data
|
||||
logger.info(f"User {user_id}: Allowing setup without bakery-type-selection (will be auto-set for enterprise)")
|
||||
required_steps.remove("bakery-type-selection")
|
||||
|
||||
for required_step in required_steps:
|
||||
if not user_progress_data.get(required_step, {}).get("completed", False):
|
||||
logger.debug(f"Step {step_name} blocked for user {user_id}: missing dependency {required_step}")
|
||||
return False
|
||||
|
||||
|
||||
# SPECIAL VALIDATION FOR ML TRAINING STEP
|
||||
if step_name == "ml-training":
|
||||
# ML training requires AI-assisted path completion
|
||||
|
||||
@@ -179,6 +179,7 @@ class EnhancedAuthService:
|
||||
onboarding_repo = OnboardingRepository(db_session)
|
||||
plan_data = {
|
||||
"subscription_plan": user_data.subscription_plan or "starter",
|
||||
"subscription_tier": user_data.subscription_plan or "starter", # Store tier for enterprise onboarding logic
|
||||
"use_trial": user_data.use_trial or False,
|
||||
"payment_method_id": user_data.payment_method_id,
|
||||
"saved_at": datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@@ -8,6 +8,8 @@ from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.inventory_service import InventoryService
|
||||
@@ -25,6 +27,8 @@ from shared.auth.access_control import require_user_role, admin_role_required, o
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('inventory')
|
||||
|
||||
@@ -61,6 +65,58 @@ async def create_ingredient(
|
||||
):
|
||||
"""Create a new ingredient (Admin/Manager only)"""
|
||||
try:
|
||||
# CRITICAL: Check subscription limit before creating
|
||||
from app.core.config import settings
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
try:
|
||||
limit_check_response = await client.get(
|
||||
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/subscriptions/{tenant_id}/can-add-product",
|
||||
headers={
|
||||
"x-user-id": str(current_user.get('user_id')),
|
||||
"x-tenant-id": str(tenant_id)
|
||||
}
|
||||
)
|
||||
|
||||
if limit_check_response.status_code == 200:
|
||||
limit_check = limit_check_response.json()
|
||||
|
||||
if not limit_check.get('can_add', False):
|
||||
logger.warning(
|
||||
"Product 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": "product_limit_exceeded",
|
||||
"message": limit_check.get('reason', 'Product limit exceeded'),
|
||||
"current_count": limit_check.get('current_count'),
|
||||
"max_allowed": limit_check.get('max_allowed'),
|
||||
"upgrade_required": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to check product limit, allowing creation",
|
||||
tenant_id=str(tenant_id),
|
||||
status_code=limit_check_response.status_code
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(
|
||||
"Timeout checking product limit, allowing creation",
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(
|
||||
"Error checking product limit, allowing creation",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Extract user ID - handle service tokens
|
||||
raw_user_id = current_user.get('user_id')
|
||||
if current_user.get('type') == 'service':
|
||||
@@ -73,13 +129,28 @@ async def create_ingredient(
|
||||
|
||||
service = InventoryService()
|
||||
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
|
||||
|
||||
logger.info(
|
||||
"Ingredient created successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient.id),
|
||||
ingredient_name=ingredient.name
|
||||
)
|
||||
|
||||
return ingredient
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create ingredient",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create ingredient"
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..services.recipe_service import RecipeService
|
||||
@@ -51,6 +52,46 @@ async def create_recipe(
|
||||
):
|
||||
"""Create a new recipe"""
|
||||
try:
|
||||
# CRITICAL: Check subscription limit before creating
|
||||
from ..core.config import settings
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
try:
|
||||
# Check recipe limit (not product limit)
|
||||
limit_check_response = await client.get(
|
||||
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/recipes/can-add",
|
||||
headers={
|
||||
"x-user-id": str(current_user.get('user_id')),
|
||||
"x-tenant-id": str(tenant_id)
|
||||
}
|
||||
)
|
||||
|
||||
if limit_check_response.status_code == 200:
|
||||
limit_check = limit_check_response.json()
|
||||
|
||||
if not limit_check.get('can_add', False):
|
||||
logger.warning(
|
||||
f"Recipe limit exceeded for tenant {tenant_id}: {limit_check.get('reason')}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": "recipe_limit_exceeded",
|
||||
"message": limit_check.get('reason', 'Recipe limit exceeded'),
|
||||
"current_count": limit_check.get('current_count'),
|
||||
"max_allowed": limit_check.get('max_allowed'),
|
||||
"upgrade_required": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to check recipe limit for tenant {tenant_id}, allowing creation"
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"Timeout checking recipe limit for tenant {tenant_id}, allowing creation")
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(f"Error checking recipe limit for tenant {tenant_id}: {e}, allowing creation")
|
||||
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
recipe_dict = recipe_data.dict(exclude={"ingredients"})
|
||||
@@ -67,6 +108,8 @@ async def create_recipe(
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
logger.info(f"Recipe created successfully for tenant {tenant_id}: {result['data'].get('name')}")
|
||||
|
||||
return RecipeResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
import httpx
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -42,6 +43,51 @@ async def create_supplier(
|
||||
):
|
||||
"""Create a new supplier"""
|
||||
try:
|
||||
# CRITICAL: Check subscription limit before creating
|
||||
from app.core.config import settings
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
try:
|
||||
limit_check_response = await client.get(
|
||||
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/suppliers/can-add",
|
||||
headers={
|
||||
"x-user-id": str(current_user.get('user_id')),
|
||||
"x-tenant-id": str(tenant_id)
|
||||
}
|
||||
)
|
||||
|
||||
if limit_check_response.status_code == 200:
|
||||
limit_check = limit_check_response.json()
|
||||
|
||||
if not limit_check.get('can_add', False):
|
||||
logger.warning(
|
||||
"Supplier limit exceeded",
|
||||
tenant_id=tenant_id,
|
||||
current=limit_check.get('current_count'),
|
||||
max=limit_check.get('max_allowed'),
|
||||
reason=limit_check.get('reason')
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": "supplier_limit_exceeded",
|
||||
"message": limit_check.get('reason', 'Supplier limit exceeded'),
|
||||
"current_count": limit_check.get('current_count'),
|
||||
"max_allowed": limit_check.get('max_allowed'),
|
||||
"upgrade_required": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to check supplier limit, allowing creation",
|
||||
tenant_id=tenant_id,
|
||||
status_code=limit_check_response.status_code
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("Timeout checking supplier limit, allowing creation", tenant_id=tenant_id)
|
||||
except httpx.RequestError as e:
|
||||
logger.warning("Error checking supplier limit, allowing creation", tenant_id=tenant_id, error=str(e))
|
||||
|
||||
service = SupplierService(db)
|
||||
|
||||
# Get user role from current_user dict
|
||||
@@ -53,7 +99,12 @@ async def create_supplier(
|
||||
created_by=current_user["user_id"],
|
||||
created_by_role=user_role
|
||||
)
|
||||
|
||||
logger.info("Supplier created successfully", tenant_id=tenant_id, supplier_id=str(supplier.id), supplier_name=supplier.name)
|
||||
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -175,7 +175,7 @@ def upgrade() -> None:
|
||||
sa.UniqueConstraint('tenant_id')
|
||||
)
|
||||
|
||||
# Create subscriptions table with all current columns
|
||||
# Create subscriptions table with all quota columns
|
||||
op.create_table('subscriptions',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
@@ -189,9 +189,33 @@ def upgrade() -> None:
|
||||
sa.Column('cancellation_effective_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('stripe_subscription_id', sa.String(255), nullable=True),
|
||||
sa.Column('stripe_customer_id', sa.String(255), nullable=True),
|
||||
# Basic resource limits
|
||||
sa.Column('max_users', sa.Integer(), nullable=True),
|
||||
sa.Column('max_locations', sa.Integer(), nullable=True),
|
||||
sa.Column('max_products', sa.Integer(), nullable=True),
|
||||
sa.Column('max_recipes', sa.Integer(), nullable=True),
|
||||
sa.Column('max_suppliers', sa.Integer(), nullable=True),
|
||||
# Daily/hourly quota limits
|
||||
sa.Column('training_jobs_per_day', sa.Integer(), nullable=True),
|
||||
sa.Column('forecast_generation_per_day', sa.Integer(), nullable=True),
|
||||
sa.Column('api_calls_per_hour', sa.Integer(), nullable=True),
|
||||
# Storage limits
|
||||
sa.Column('file_storage_gb', sa.Integer(), nullable=True),
|
||||
# Data access limits
|
||||
sa.Column('dataset_size_rows', sa.Integer(), nullable=True),
|
||||
sa.Column('forecast_horizon_days', sa.Integer(), nullable=True),
|
||||
sa.Column('historical_data_access_days', sa.Integer(), nullable=True),
|
||||
# Bulk operation limits
|
||||
sa.Column('bulk_import_rows', sa.Integer(), nullable=True),
|
||||
sa.Column('bulk_export_rows', sa.Integer(), nullable=True),
|
||||
# Integration limits
|
||||
sa.Column('webhook_endpoints', sa.Integer(), nullable=True),
|
||||
sa.Column('pos_sync_interval_minutes', sa.Integer(), nullable=True),
|
||||
# Reporting limits
|
||||
sa.Column('report_retention_days', sa.Integer(), nullable=True),
|
||||
# Enterprise-specific limits
|
||||
sa.Column('max_child_tenants', sa.Integer(), nullable=True),
|
||||
# Features and metadata
|
||||
sa.Column('features', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
Reference in New Issue
Block a user