""" Tenant Operations API - BUSINESS operations Handles complex tenant operations, registration, search, subscriptions, and analytics """ import structlog from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status, Path, Query from typing import List, Dict, Any, Optional from uuid import UUID import shared.redis_utils from app.schemas.tenants import ( BakeryRegistration, TenantResponse, TenantAccessResponse, TenantSearchRequest ) from app.services.tenant_service import EnhancedTenantService from app.services.subscription_limit_service import SubscriptionLimitService from app.services.payment_service import PaymentService from app.models import AuditLog from shared.auth.decorators import ( get_current_user_dep, require_admin_role_dep ) from app.core.database import get_db from sqlalchemy.ext.asyncio import AsyncSession from shared.auth.access_control import owner_role_required, admin_role_required from shared.routing.route_builder import RouteBuilder from shared.database.base import create_database_manager from shared.monitoring.metrics import track_endpoint_metrics from shared.security import create_audit_logger, AuditSeverity, AuditAction from shared.config.base import is_internal_service logger = structlog.get_logger() router = APIRouter() route_builder = RouteBuilder("tenants") # Initialize audit logger audit_logger = create_audit_logger("tenant-service", AuditLog) # Global Redis client _redis_client = None # Dependency injection for enhanced tenant service def get_enhanced_tenant_service(): try: from app.core.config import settings database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") return EnhancedTenantService(database_manager) except Exception as e: logger.error("Failed to create enhanced tenant service", error=str(e)) raise HTTPException(status_code=500, detail="Service initialization failed") async def get_tenant_redis_client(): """Get or create Redis client""" global _redis_client try: if _redis_client is None: from app.core.config import settings _redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL) logger.info("Redis client initialized using shared utilities") return _redis_client except Exception as e: logger.warning("Failed to initialize Redis client, service will work with limited functionality", error=str(e)) return None def get_subscription_limit_service(): try: from app.core.config import settings database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") # Get Redis client properly (it's an async function) import asyncio try: # Try to get the event loop, if we're in an async context loop = asyncio.get_event_loop() if loop.is_running(): # If we're in a running event loop, we can't use await here # So we'll pass None and handle Redis initialization in the service redis_client = None else: redis_client = asyncio.run(get_tenant_redis_client()) except RuntimeError: # No event loop running, we can use async/await redis_client = asyncio.run(get_tenant_redis_client()) return SubscriptionLimitService(database_manager, redis_client) except Exception as e: logger.error("Failed to create subscription limit service", error=str(e)) raise HTTPException(status_code=500, detail="Service initialization failed") def get_payment_service(): try: return PaymentService() except Exception as e: logger.error("Failed to create payment service", error=str(e)) raise HTTPException(status_code=500, detail="Payment service initialization failed") # TENANT REGISTRATION & ACCESS OPERATIONS # ============================================================================ @router.post(route_builder.build_base_route("register", include_tenant_prefix=False), response_model=TenantResponse) async def register_bakery( bakery_data: BakeryRegistration, current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service), payment_service: PaymentService = Depends(get_payment_service), db: AsyncSession = Depends(get_db) ): """Register a new bakery/tenant with enhanced validation and features""" try: # Initialize variables to avoid UnboundLocalError coupon_validation = None success = None discount = None error = None # Create bakery/tenant first result = await tenant_service.create_bakery( bakery_data, current_user["user_id"] ) tenant_id = result.id # NEW ARCHITECTURE: Check if we need to link an existing subscription if bakery_data.link_existing_subscription and bakery_data.subscription_id: logger.info("Linking existing subscription to new tenant", tenant_id=tenant_id, subscription_id=bakery_data.subscription_id, user_id=current_user["user_id"]) try: # Import subscription service for linking from app.services.subscription_service import SubscriptionService subscription_service = SubscriptionService(db) # Link the subscription to the tenant linking_result = await subscription_service.link_subscription_to_tenant( subscription_id=bakery_data.subscription_id, tenant_id=tenant_id, user_id=current_user["user_id"] ) logger.info("Subscription linked successfully during tenant registration", tenant_id=tenant_id, subscription_id=bakery_data.subscription_id) except Exception as linking_error: logger.error("Error linking subscription during tenant registration", tenant_id=tenant_id, subscription_id=bakery_data.subscription_id, error=str(linking_error)) # Don't fail tenant creation if subscription linking fails # The subscription can be linked later manually elif bakery_data.coupon_code: # If no subscription but coupon provided, just validate and redeem coupon coupon_validation = payment_service.validate_coupon_code( bakery_data.coupon_code, tenant_id, db ) if not coupon_validation["valid"]: logger.warning( "Invalid coupon code provided during registration", coupon_code=bakery_data.coupon_code, error=coupon_validation["error_message"] ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=coupon_validation["error_message"] ) # Redeem coupon success, discount, error = payment_service.redeem_coupon( bakery_data.coupon_code, tenant_id, db ) if success: logger.info("Coupon redeemed during registration", coupon_code=bakery_data.coupon_code, tenant_id=tenant_id) else: logger.warning("Failed to redeem coupon during registration", coupon_code=bakery_data.coupon_code, error=error) else: # No subscription plan provided - check if tenant already has a subscription # (from new registration flow where subscription is created first) try: from app.repositories.subscription_repository import SubscriptionRepository from app.models.tenants import Subscription from datetime import datetime, timedelta, timezone from app.core.config import settings database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") async with database_manager.get_session() as session: subscription_repo = SubscriptionRepository(Subscription, session) # Check if tenant already has an active subscription existing_subscription = await subscription_repo.get_by_tenant_id(str(result.id)) if existing_subscription: logger.info( "Tenant already has an active subscription, skipping default subscription creation", tenant_id=str(result.id), existing_plan=existing_subscription.plan, subscription_id=str(existing_subscription.id) ) else: # 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": "trial", "billing_cycle": "monthly", "next_billing_date": next_billing_date, "trial_ends_at": trial_end_date }) await session.commit() logger.info( "Default free trial 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) ) # If coupon was validated, redeem it now with actual tenant_id if coupon_validation and coupon_validation["valid"]: from app.core.config import settings database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") async with database_manager.get_session() as session: success, discount, error = payment_service.redeem_coupon( bakery_data.coupon_code, result.id, session ) if success: logger.info( "Coupon redeemed successfully", tenant_id=result.id, coupon_code=bakery_data.coupon_code, discount=discount ) else: logger.warning( "Failed to redeem coupon after registration", tenant_id=result.id, coupon_code=bakery_data.coupon_code, error=error ) logger.info("Bakery registered successfully", name=bakery_data.name, owner_email=current_user.get('email'), tenant_id=result.id, coupon_applied=bakery_data.coupon_code is not None) return result except HTTPException: raise except Exception as e: logger.error("Bakery registration failed", name=bakery_data.name, owner_id=current_user["user_id"], error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bakery registration failed" ) @router.get(route_builder.build_base_route("{tenant_id}/my-access", include_tenant_prefix=False), response_model=TenantAccessResponse) async def get_current_user_tenant_access( tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep) ): """Get current user's access to tenant with role and permissions""" try: # Create tenant service directly from app.core.config import settings database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") tenant_service = EnhancedTenantService(database_manager) access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id)) return access_info except Exception as e: logger.error("Current user access verification failed", user_id=current_user["user_id"], tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access verification failed" ) @router.get(route_builder.build_base_route("{tenant_id}/access/{user_id}", include_tenant_prefix=False), response_model=TenantAccessResponse) async def verify_tenant_access( tenant_id: UUID = Path(..., description="Tenant ID"), user_id: str = Path(..., description="User ID") ): """Verify if user has access to tenant - Enhanced version with detailed permissions""" # Check if this is an internal service request using centralized registry if is_internal_service(user_id): # Services have access to all tenants for their operations logger.info("Service access granted", service=user_id, tenant_id=str(tenant_id)) return TenantAccessResponse( has_access=True, role="service", permissions=["read", "write"] ) try: # Create tenant service directly from app.core.config import settings database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") tenant_service = EnhancedTenantService(database_manager) access_info = await tenant_service.verify_user_access(user_id, str(tenant_id)) return access_info except Exception as e: logger.error("Access verification failed", user_id=user_id, tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access verification failed" ) # ============================================================================ # TENANT SEARCH & DISCOVERY OPERATIONS # ============================================================================ @router.get(route_builder.build_base_route("subdomain/{subdomain}", include_tenant_prefix=False), response_model=TenantResponse) @track_endpoint_metrics("tenant_get_by_subdomain") async def get_tenant_by_subdomain( subdomain: str = Path(..., description="Tenant subdomain"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get tenant by subdomain with enhanced validation""" tenant = await tenant_service.get_tenant_by_subdomain(subdomain) if not tenant: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found" ) # Verify user has access to this tenant access = await tenant_service.verify_user_access(current_user["user_id"], tenant.id) if not access.has_access: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to tenant" ) return tenant @router.get(route_builder.build_base_route("user/{user_id}/owned", include_tenant_prefix=False), response_model=List[TenantResponse]) async def get_user_owned_tenants( user_id: str = Path(..., description="User ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get all tenants owned by a user with enhanced data""" # Users can only get their own tenants unless they're admin user_role = current_user.get('role', '').lower() # Handle demo user: frontend uses "demo-user" but backend has actual demo user UUID is_demo_user = current_user.get("is_demo", False) and user_id == "demo-user" if user_id != current_user["user_id"] and not is_demo_user and user_role != 'admin': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Can only access your own tenants" ) # For demo sessions, we need to handle the special case where virtual tenants are not owned by the # demo user ID but are instead associated with the demo session if current_user.get("is_demo", False): # Extract demo session info from headers (gateway should set this when processing demo tokens) demo_session_id = current_user.get("demo_session_id") demo_account_type = current_user.get("demo_account_type", "") if demo_session_id: # For demo sessions, get virtual tenants associated with the session # Rather than returning all tenants owned by the shared demo user ID logger.info("Fetching virtual tenants for demo session", demo_session_id=demo_session_id, demo_account_type=demo_account_type) # Special logic for demo sessions: return virtual tenants associated with this session virtual_tenants = await tenant_service.get_virtual_tenants_for_session(demo_session_id, demo_account_type) return virtual_tenants else: # Fallback: if no session ID but is a demo user, return based on account type # Individual bakery demo user should have access to the professional demo tenant # Enterprise demo session should have access only to enterprise parent tenant and its child virtual_tenants = await tenant_service.get_demo_tenants_by_session_type( demo_account_type, str(current_user["user_id"]) ) return virtual_tenants # For regular users, use the original logic actual_user_id = current_user["user_id"] if is_demo_user else user_id tenants = await tenant_service.get_user_tenants(actual_user_id) return tenants @router.get(route_builder.build_base_route("search", include_tenant_prefix=False), response_model=List[TenantResponse]) @track_endpoint_metrics("tenant_search") async def search_tenants( search_term: str = Query(..., description="Search term"), business_type: Optional[str] = Query(None, description="Business type filter"), city: Optional[str] = Query(None, description="City filter"), skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(50, ge=1, le=100, description="Maximum number of records to return"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Search tenants with advanced filters and pagination""" tenants = await tenant_service.search_tenants( search_term=search_term, business_type=business_type, city=city, skip=skip, limit=limit ) return tenants @router.get(route_builder.build_base_route("nearby", include_tenant_prefix=False), response_model=List[TenantResponse]) @track_endpoint_metrics("tenant_get_nearby") async def get_nearby_tenants( latitude: float = Query(..., description="Latitude coordinate"), longitude: float = Query(..., description="Longitude coordinate"), radius_km: float = Query(10.0, ge=0.1, le=100.0, description="Search radius in kilometers"), limit: int = Query(50, ge=1, le=100, description="Maximum number of results"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get tenants near a geographic location with enhanced geospatial search""" tenants = await tenant_service.get_tenants_near_location( latitude=latitude, longitude=longitude, radius_km=radius_km, limit=limit ) return tenants @router.get(route_builder.build_base_route("users/{user_id}", include_tenant_prefix=False), response_model=List[TenantResponse]) @track_endpoint_metrics("tenant_get_user_tenants") async def get_user_tenants( user_id: str = Path(..., description="User ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get all tenants owned by a user - Fixed endpoint for frontend""" # Security check: users can only access their own tenants unless they're admin or demo user is_demo_user = current_user.get("is_demo", False) is_service_account = current_user.get("type") == "service" user_role = current_user.get('role', '').lower() if user_id != current_user["user_id"] and not is_service_account and not (is_demo_user and user_id == "demo-user") and user_role != 'admin': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Can only access your own tenants" ) try: tenants = await tenant_service.get_user_tenants(user_id) logger.info("Retrieved user tenants", user_id=user_id, tenant_count=len(tenants)) return tenants except Exception as e: logger.error("Get user tenants failed", user_id=user_id, error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get user tenants" ) @router.get(route_builder.build_base_route("members/user/{user_id}", include_tenant_prefix=False)) @track_endpoint_metrics("tenant_get_user_memberships") async def get_user_memberships( user_id: str = Path(..., description="User ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get all tenant memberships for a user (for authentication service)""" # Security check: users can only access their own memberships unless they're admin or demo user is_demo_user = current_user.get("is_demo", False) is_service_account = current_user.get("type") == "service" user_role = current_user.get('role', '').lower() if user_id != current_user["user_id"] and not is_service_account and not (is_demo_user and user_id == "demo-user") and user_role != 'admin': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Can only access your own memberships" ) try: memberships = await tenant_service.get_user_memberships(user_id) logger.info("Retrieved user memberships", user_id=user_id, membership_count=len(memberships)) return memberships except Exception as e: logger.error("Get user memberships failed", user_id=user_id, error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get user memberships" ) # ============================================================================ # TENANT MODEL STATUS OPERATIONS # ============================================================================ @router.put(route_builder.build_base_route("{tenant_id}/model-status", include_tenant_prefix=False)) @track_endpoint_metrics("tenant_update_model_status") async def update_tenant_model_status( tenant_id: UUID = Path(..., description="Tenant ID"), ml_model_trained: bool = Query(..., description="Whether model is trained"), last_training_date: Optional[datetime] = Query(None, description="Last training date"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Update tenant model training status with enhanced tracking""" try: result = await tenant_service.update_model_status( str(tenant_id), ml_model_trained, current_user["user_id"], last_training_date ) return result except HTTPException: raise except Exception as e: logger.error("Model status update failed", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update model status" ) # ============================================================================ # TENANT ACTIVATION/DEACTIVATION OPERATIONS # ============================================================================ @router.post(route_builder.build_base_route("{tenant_id}/deactivate", include_tenant_prefix=False)) @track_endpoint_metrics("tenant_deactivate") @owner_role_required async def deactivate_tenant( tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Deactivate a tenant (owner only) with enhanced validation""" try: success = await tenant_service.deactivate_tenant( str(tenant_id), current_user["user_id"] ) if success: # Log audit event for tenant deactivation try: from app.core.database import get_db_session async with get_db_session() as db: await audit_logger.log_event( db_session=db, tenant_id=str(tenant_id), user_id=current_user["user_id"], action=AuditAction.DEACTIVATE.value, resource_type="tenant", resource_id=str(tenant_id), severity=AuditSeverity.CRITICAL.value, description=f"Owner {current_user.get('email', current_user['user_id'])} deactivated tenant", endpoint="/{tenant_id}/deactivate", method="POST" ) except Exception as audit_error: logger.warning("Failed to log audit event", error=str(audit_error)) return {"success": True, "message": "Tenant deactivated successfully"} else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to deactivate tenant" ) except HTTPException: raise except Exception as e: logger.error("Tenant deactivation failed", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to deactivate tenant" ) @router.post(route_builder.build_base_route("{tenant_id}/activate", include_tenant_prefix=False)) @track_endpoint_metrics("tenant_activate") @owner_role_required async def activate_tenant( tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Activate a previously deactivated tenant (owner only) with enhanced validation""" try: success = await tenant_service.activate_tenant( str(tenant_id), current_user["user_id"] ) if success: # Log audit event for tenant activation try: from app.core.database import get_db_session async with get_db_session() as db: await audit_logger.log_event( db_session=db, tenant_id=str(tenant_id), user_id=current_user["user_id"], action=AuditAction.ACTIVATE.value, resource_type="tenant", resource_id=str(tenant_id), severity=AuditSeverity.HIGH.value, description=f"Owner {current_user.get('email', current_user['user_id'])} activated tenant", endpoint="/{tenant_id}/activate", method="POST" ) except Exception as audit_error: logger.warning("Failed to log audit event", error=str(audit_error)) return {"success": True, "message": "Tenant activated successfully"} else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to activate tenant" ) except HTTPException: raise except Exception as e: logger.error("Tenant activation failed", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to activate tenant" ) # ============================================================================ # TENANT STATISTICS & ANALYTICS # ============================================================================ @router.get(route_builder.build_base_route("statistics", include_tenant_prefix=False), dependencies=[Depends(require_admin_role_dep)]) @track_endpoint_metrics("tenant_get_statistics") async def get_tenant_statistics( current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get comprehensive tenant statistics (admin only) with enhanced analytics""" try: stats = await tenant_service.get_tenant_statistics() return stats except Exception as e: logger.error("Get tenant statistics failed", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get tenant statistics" ) # ============================================================================ # SUBSCRIPTION OPERATIONS # ============================================================================ @router.get("/api/v1/subscriptions/{tenant_id}/tier") async def get_tenant_subscription_tier_fast( tenant_id: UUID = Path(..., description="Tenant ID"), redis_client = Depends(get_tenant_redis_client) ): """ Fast cached lookup for tenant subscription tier This endpoint is optimized for high-frequency access (e.g., from gateway middleware) with Redis caching (10-minute TTL). No authentication required for internal service calls. """ try: from app.services.subscription_cache import get_subscription_cache_service cache_service = get_subscription_cache_service(redis_client) tier = await cache_service.get_tenant_tier_cached(str(tenant_id)) return { "tenant_id": str(tenant_id), "tier": tier, "cached": True } except Exception as e: logger.error("Failed to get subscription tier", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get subscription tier" ) @router.get(route_builder.build_base_route("subscriptions/{tenant_id}/active", include_tenant_prefix=False)) async def get_tenant_active_subscription( tenant_id: UUID = Path(..., description="Tenant ID"), redis_client = Depends(get_tenant_redis_client) ): """ Get full active subscription with caching Returns complete subscription details with 10-minute Redis cache. """ try: from app.services.subscription_cache import get_subscription_cache_service cache_service = get_subscription_cache_service(redis_client) subscription = await cache_service.get_tenant_subscription_cached(str(tenant_id)) if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No active subscription found" ) return subscription except HTTPException: raise except Exception as e: logger.error("Failed to get active subscription", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get active subscription" ) @router.get(route_builder.build_base_route("subscriptions/{tenant_id}/limits", include_tenant_prefix=False)) async def get_subscription_limits( 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) ): """Get current subscription limits for a tenant""" try: limits = await limit_service.get_tenant_subscription_limits(str(tenant_id)) return limits except Exception as e: logger.error("Failed to get subscription limits", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get subscription limits" ) @router.get(route_builder.build_base_route("subscriptions/{tenant_id}/usage", include_tenant_prefix=False)) async def get_usage_summary( 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) ): """Get usage summary vs limits for a tenant (cached for 30s for performance)""" try: # Try to get from cache first (30s TTL) from shared.redis_utils import get_redis_client import json cache_key = f"usage_summary:{tenant_id}" redis_client = await get_redis_client() if redis_client: cached = await redis_client.get(cache_key) if cached: logger.debug("Usage summary cache hit", tenant_id=str(tenant_id)) return json.loads(cached) # Cache miss - fetch fresh data usage = await limit_service.get_usage_summary(str(tenant_id)) # Store in cache with 30s TTL if redis_client: await redis_client.setex(cache_key, 30, json.dumps(usage)) logger.debug("Usage summary cached", tenant_id=str(tenant_id)) return usage except Exception as e: logger.error("Failed to get usage summary", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get usage summary" ) @router.get(route_builder.build_base_route("subscriptions/{tenant_id}/can-add-location", include_tenant_prefix=False)) async def can_add_location( 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 location""" try: result = await limit_service.can_add_location(str(tenant_id)) return result except Exception as e: logger.error("Failed to check location limits", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to check location limits" ) @router.get(route_builder.build_base_route("subscriptions/{tenant_id}/can-add-product", include_tenant_prefix=False)) async def can_add_product( 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 product""" try: result = await limit_service.can_add_product(str(tenant_id)) return result except Exception as e: logger.error("Failed to check product limits", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to check product limits" ) @router.get(route_builder.build_base_route("subscriptions/{tenant_id}/can-add-user", include_tenant_prefix=False)) async def can_add_user( 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 user/member""" try: result = await limit_service.can_add_user(str(tenant_id)) return result except Exception as e: logger.error("Failed to check user limits", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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"), feature: str = Path(..., description="Feature name"), current_user: Dict[str, Any] = Depends(get_current_user_dep), limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) ): """Check if tenant has access to a specific feature""" try: result = await limit_service.has_feature(str(tenant_id), feature) return result except Exception as e: logger.error("Failed to check feature access", tenant_id=str(tenant_id), feature=feature, error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to check feature access" ) @router.get(route_builder.build_base_route("subscriptions/{tenant_id}/validate-upgrade/{new_plan}", include_tenant_prefix=False)) async def validate_plan_upgrade( tenant_id: UUID = Path(..., description="Tenant ID"), new_plan: str = Path(..., description="New plan name"), current_user: Dict[str, Any] = Depends(get_current_user_dep), limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) ): """Validate if tenant can upgrade to a new plan""" try: result = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan) return result except Exception as e: logger.error("Failed to validate plan upgrade", tenant_id=str(tenant_id), new_plan=new_plan, error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to validate plan upgrade" ) @router.post(route_builder.build_base_route("subscriptions/{tenant_id}/upgrade", include_tenant_prefix=False)) async def upgrade_subscription_plan( tenant_id: UUID = Path(..., description="Tenant ID"), new_plan: str = Query(..., description="New plan name"), current_user: Dict[str, Any] = Depends(get_current_user_dep), limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) ): """Upgrade subscription plan for a tenant""" try: # First validate the upgrade validation = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan) if not validation.get("can_upgrade", False): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=validation.get("reason", "Cannot upgrade to this plan") ) # Actually update the subscription plan in the database from app.core.config import settings from app.repositories.subscription_repository import SubscriptionRepository from app.models.tenants import Subscription from shared.database.base import create_database_manager database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") async with database_manager.get_session() as session: subscription_repo = SubscriptionRepository(Subscription, session) # Get the active subscription for this tenant active_subscription = await subscription_repo.get_active_subscription(str(tenant_id)) if not active_subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No active subscription found for this tenant" ) # Update the subscription plan updated_subscription = await subscription_repo.update_subscription_plan( str(active_subscription.id), new_plan ) # Commit the changes await session.commit() logger.info("Subscription plan upgraded successfully", tenant_id=str(tenant_id), subscription_id=str(active_subscription.id), old_plan=active_subscription.plan, new_plan=new_plan, user_id=current_user["user_id"]) # Invalidate subscription cache to ensure immediate availability of new tier try: from app.services.subscription_cache import get_subscription_cache_service import shared.redis_utils from app.core.config import settings 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 upgrade", tenant_id=str(tenant_id), new_plan=new_plan) except Exception as cache_error: logger.error("Failed to invalidate subscription cache after upgrade", tenant_id=str(tenant_id), error=str(cache_error)) # Don't fail the upgrade if cache invalidation fails # SECURITY: Invalidate all existing tokens for this tenant # Forces users to re-authenticate and get new JWT with updated tier try: await _invalidate_tenant_tokens(tenant_id, redis_client) logger.info("Invalidated all tokens for tenant after subscription upgrade", tenant_id=str(tenant_id)) except Exception as token_error: logger.error("Failed to invalidate tenant tokens after upgrade", tenant_id=str(tenant_id), error=str(token_error)) # Don't fail the upgrade if token invalidation fails # Also publish event for real-time notification try: from shared.messaging import UnifiedEventPublisher event_publisher = UnifiedEventPublisher() await event_publisher.publish_business_event( event_type="subscription.changed", tenant_id=str(tenant_id), data={ "tenant_id": str(tenant_id), "old_tier": active_subscription.plan, "new_tier": new_plan, "action": "upgrade" } ) logger.info("Published subscription change event", tenant_id=str(tenant_id), event_type="subscription.changed") except Exception as event_error: logger.error("Failed to publish subscription change event", tenant_id=str(tenant_id), error=str(event_error)) return { "success": True, "message": f"Plan successfully upgraded to {new_plan}", "old_plan": active_subscription.plan, "new_plan": new_plan, "new_monthly_price": updated_subscription.monthly_price, "validation": validation, "requires_token_refresh": True # Signal to frontend } except HTTPException: raise except Exception as e: logger.error("Failed to upgrade subscription plan", tenant_id=str(tenant_id), new_plan=new_plan, error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to upgrade subscription plan" ) # ============================================================================ # PAYMENT OPERATIONS # ============================================================================ # Note: /plans endpoint moved to app/api/plans.py for better organization @router.post(route_builder.build_base_route("subscriptions/register-with-subscription", include_tenant_prefix=False)) async def register_with_subscription( user_data: Dict[str, Any], plan_id: str = Query(..., description="Plan ID to subscribe to (starter, professional, enterprise)"), payment_method_id: str = Query(..., description="Payment method ID from frontend"), coupon_code: str = Query(None, description="Coupon code for discounts or trial periods"), billing_interval: str = Query("monthly", description="Billing interval (monthly or yearly)"), payment_service: PaymentService = Depends(get_payment_service) ): """Process user registration with subscription creation""" @router.post(route_builder.build_base_route("payment-customers/create", include_tenant_prefix=False)) async def create_payment_customer( user_data: Dict[str, Any], payment_method_id: Optional[str] = Query(None, description="Optional payment method ID"), payment_service: PaymentService = Depends(get_payment_service) ): """ Create a payment customer in the payment provider This endpoint is designed for service-to-service communication from auth service during user registration. It creates a payment customer that can be used later for subscription creation. Args: user_data: User data including email, name, etc. payment_method_id: Optional payment method ID to attach Returns: Dictionary with payment customer details """ try: logger.info("Creating payment customer via service-to-service call", email=user_data.get('email'), user_id=user_data.get('user_id')) # Step 1: Create payment customer customer = await payment_service.create_customer(user_data) logger.info("Payment customer created successfully", customer_id=customer.id, email=customer.email) # Step 2: Attach payment method if provided payment_method_details = None if payment_method_id: try: payment_method = await payment_service.update_payment_method( customer.id, payment_method_id ) payment_method_details = { "id": payment_method.id, "type": payment_method.type, "brand": payment_method.brand, "last4": payment_method.last4, "exp_month": payment_method.exp_month, "exp_year": payment_method.exp_year } logger.info("Payment method attached to customer", customer_id=customer.id, payment_method_id=payment_method.id) except Exception as e: logger.warning("Failed to attach payment method to customer", customer_id=customer.id, error=str(e), payment_method_id=payment_method_id) # Continue without attached payment method # Step 3: Return comprehensive result return { "success": True, "payment_customer_id": customer.id, "payment_method": payment_method_details, "customer": { "id": customer.id, "email": customer.email, "name": customer.name, "created_at": customer.created_at.isoformat() } } except Exception as e: logger.error("Failed to create payment customer via service-to-service call", error=str(e), email=user_data.get('email'), user_id=user_data.get('user_id')) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create payment customer: {str(e)}" ) @router.post(route_builder.build_base_route("subscriptions/register-with-subscription", include_tenant_prefix=False)) async def register_with_subscription( user_data: Dict[str, Any], plan_id: str = Query(..., description="Plan ID to subscribe to (starter, professional, enterprise)"), payment_method_id: str = Query(..., description="Payment method ID from frontend"), coupon_code: str = Query(None, description="Coupon code for discounts or trial periods"), billing_interval: str = Query("monthly", description="Billing interval (monthly or yearly)"), payment_service: PaymentService = Depends(get_payment_service) ): """Process user registration with subscription creation""" try: result = await payment_service.process_registration_with_subscription( user_data, plan_id, payment_method_id, coupon_code, billing_interval ) return { "success": True, "message": "Registration and subscription created successfully", "data": result } except Exception as e: logger.error("Failed to register with subscription", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to register with subscription" ) @router.post(route_builder.build_base_route("subscriptions/link", include_tenant_prefix=False)) async def link_subscription_to_tenant( tenant_id: str = Query(..., description="Tenant ID to link subscription to"), subscription_id: str = Query(..., description="Subscription ID to link"), user_id: str = Query(..., description="User ID performing the linking"), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service), db: AsyncSession = Depends(get_db) ): """ Link a pending subscription to a tenant This endpoint completes the registration flow by associating the subscription created during registration with the tenant created during onboarding. Args: tenant_id: Tenant ID to link to subscription_id: Subscription ID to link user_id: User ID performing the linking (for validation) Returns: Dictionary with linking results """ try: logger.info("Linking subscription to tenant", tenant_id=tenant_id, subscription_id=subscription_id, user_id=user_id) # Link subscription to tenant result = await tenant_service.link_subscription_to_tenant( tenant_id, subscription_id, user_id ) logger.info("Subscription linked to tenant successfully", tenant_id=tenant_id, subscription_id=subscription_id, user_id=user_id) return { "success": True, "message": "Subscription linked to tenant successfully", "data": result } except Exception as e: logger.error("Failed to link subscription to tenant", error=str(e), tenant_id=tenant_id, subscription_id=subscription_id, user_id=user_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to link subscription to tenant" ) async def _invalidate_tenant_tokens(tenant_id: str, redis_client): """ Invalidate all tokens for users in this tenant. Forces re-authentication to get fresh subscription data. """ try: # Set a "subscription_changed_at" timestamp for this tenant # Gateway will check this and reject tokens issued before this time import datetime from datetime import timezone changed_timestamp = datetime.datetime.now(timezone.utc).timestamp() await redis_client.set( f"tenant:{tenant_id}:subscription_changed_at", str(changed_timestamp), ex=86400 # 24 hour TTL ) logger.info("Set subscription change timestamp for token invalidation", tenant_id=tenant_id, timestamp=changed_timestamp) except Exception as e: logger.error("Failed to invalidate tenant tokens", tenant_id=tenant_id, error=str(e)) raise