2025-10-06 15:27:01 +02:00
|
|
|
"""
|
|
|
|
|
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
|
2025-10-15 16:12:49 +02:00
|
|
|
import shared.redis_utils
|
2025-10-06 15:27:01 +02:00
|
|
|
|
|
|
|
|
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
|
2025-10-29 06:58:05 +01:00
|
|
|
from app.models import AuditLog
|
2025-10-06 15:27:01 +02:00
|
|
|
from shared.auth.decorators import (
|
|
|
|
|
get_current_user_dep,
|
|
|
|
|
require_admin_role_dep
|
|
|
|
|
)
|
2025-10-15 16:12:49 +02:00
|
|
|
from shared.auth.access_control import owner_role_required, admin_role_required
|
2025-10-06 15:27:01 +02:00
|
|
|
from shared.routing.route_builder import RouteBuilder
|
|
|
|
|
from shared.database.base import create_database_manager
|
|
|
|
|
from shared.monitoring.metrics import track_endpoint_metrics
|
2025-10-15 16:12:49 +02:00
|
|
|
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
2025-10-23 07:44:54 +02:00
|
|
|
from shared.config.base import is_internal_service
|
2025-10-06 15:27:01 +02:00
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
route_builder = RouteBuilder("tenants")
|
|
|
|
|
|
2025-10-15 16:12:49 +02:00
|
|
|
# Initialize audit logger
|
2025-10-29 06:58:05 +01:00
|
|
|
audit_logger = create_audit_logger("tenant-service", AuditLog)
|
2025-10-15 16:12:49 +02:00
|
|
|
|
|
|
|
|
# Global Redis client
|
|
|
|
|
_redis_client = None
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
# 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")
|
|
|
|
|
|
2025-10-15 16:12:49 +02:00
|
|
|
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
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
def get_subscription_limit_service():
|
|
|
|
|
try:
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
2025-10-23 07:44:54 +02:00
|
|
|
|
|
|
|
|
# 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())
|
|
|
|
|
|
2025-10-15 16:12:49 +02:00
|
|
|
return SubscriptionLimitService(database_manager, redis_client)
|
2025-10-06 15:27:01 +02:00
|
|
|
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),
|
2025-10-17 18:14:28 +02:00
|
|
|
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service),
|
|
|
|
|
payment_service: PaymentService = Depends(get_payment_service)
|
2025-10-06 15:27:01 +02:00
|
|
|
):
|
|
|
|
|
"""Register a new bakery/tenant with enhanced validation and features"""
|
|
|
|
|
|
|
|
|
|
try:
|
2025-10-17 18:14:28 +02:00
|
|
|
# Validate coupon if provided
|
|
|
|
|
coupon_validation = None
|
|
|
|
|
if bakery_data.coupon_code:
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
|
|
|
|
|
|
|
|
|
async with database_manager.get_session() as session:
|
|
|
|
|
# Temp tenant ID for validation (will be replaced with actual after creation)
|
|
|
|
|
temp_tenant_id = f"temp_{current_user['user_id']}"
|
|
|
|
|
|
|
|
|
|
coupon_validation = payment_service.validate_coupon_code(
|
|
|
|
|
bakery_data.coupon_code,
|
|
|
|
|
temp_tenant_id,
|
|
|
|
|
session
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Create bakery/tenant
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await tenant_service.create_bakery(
|
|
|
|
|
bakery_data,
|
|
|
|
|
current_user["user_id"]
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-17 18:14:28 +02:00
|
|
|
# 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
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Bakery registered successfully",
|
|
|
|
|
name=bakery_data.name,
|
|
|
|
|
owner_email=current_user.get('email'),
|
2025-10-17 18:14:28 +02:00
|
|
|
tenant_id=result.id,
|
|
|
|
|
coupon_applied=bakery_data.coupon_code is not None)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
|
|
|
|
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"""
|
|
|
|
|
|
2025-10-23 07:44:54 +02:00
|
|
|
# Check if this is an internal service request using centralized registry
|
|
|
|
|
if is_internal_service(user_id):
|
2025-10-06 15:27:01 +02:00
|
|
|
# Services have access to all tenants for their operations
|
2025-10-23 07:44:54 +02:00
|
|
|
logger.info("Service access granted", service=user_id, tenant_id=str(tenant_id))
|
2025-10-06 15:27:01 +02:00
|
|
|
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()
|
2025-11-30 09:12:40 +01:00
|
|
|
|
|
|
|
|
# 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':
|
2025-10-06 15:27:01 +02:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="Can only access your own tenants"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-30 09:12:40 +01:00
|
|
|
# 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)
|
2025-10-06 15:27:01 +02:00
|
|
|
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"),
|
2025-12-17 16:28:58 +01:00
|
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
2025-10-06 15:27:01 +02:00
|
|
|
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
|
|
|
|
):
|
|
|
|
|
"""Get all tenants owned by a user - Fixed endpoint for frontend"""
|
|
|
|
|
|
2025-12-17 16:28:58 +01:00
|
|
|
# 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"
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
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"),
|
2025-12-17 16:28:58 +01:00
|
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
2025-10-06 15:27:01 +02:00
|
|
|
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
|
|
|
|
):
|
|
|
|
|
"""Get all tenant memberships for a user (for authentication service)"""
|
|
|
|
|
|
2025-12-17 16:28:58 +01:00
|
|
|
# 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"
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
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")
|
2025-10-15 16:12:49 +02:00
|
|
|
@owner_role_required
|
2025-10-06 15:27:01 +02:00
|
|
|
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:
|
2025-10-15 16:12:49 +02:00
|
|
|
# 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))
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
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")
|
2025-10-15 16:12:49 +02:00
|
|
|
@owner_role_required
|
2025-10-06 15:27:01 +02:00
|
|
|
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:
|
2025-10-15 16:12:49 +02:00
|
|
|
# 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))
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
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
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
2025-10-29 06:58:05 +01:00
|
|
|
@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"
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@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"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
usage = await limit_service.get_usage_summary(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("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")
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-07 07:15:07 +02:00
|
|
|
# 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"])
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-10-29 06:58:05 +01:00
|
|
|
# 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
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
return {
|
|
|
|
|
"success": True,
|
2025-10-07 07:15:07 +02:00
|
|
|
"message": f"Plan successfully upgraded to {new_plan}",
|
|
|
|
|
"old_plan": active_subscription.plan,
|
|
|
|
|
"new_plan": new_plan,
|
|
|
|
|
"new_monthly_price": updated_subscription.monthly_price,
|
2025-10-06 15:27:01 +02:00
|
|
|
"validation": validation
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
# ============================================================================
|
2025-10-15 16:12:49 +02:00
|
|
|
# Note: /plans endpoint moved to app/api/plans.py for better organization
|
2025-10-06 15:27:01 +02:00
|
|
|
|
|
|
|
|
@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"),
|
|
|
|
|
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
|
|
|
|
|
use_trial: bool = Query(False, description="Whether to use trial period for pilot users"),
|
|
|
|
|
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,
|
|
|
|
|
use_trial
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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/{tenant_id}/cancel", include_tenant_prefix=False))
|
|
|
|
|
async def cancel_subscription(
|
|
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
|
|
|
payment_service: PaymentService = Depends(get_payment_service)
|
|
|
|
|
):
|
|
|
|
|
"""Cancel subscription for a tenant"""
|
|
|
|
|
|
|
|
|
|
try:
|
2025-10-24 13:05:04 +02:00
|
|
|
# Verify user is owner/admin of tenant
|
|
|
|
|
user_id = current_user.get('user_id')
|
|
|
|
|
user_role = current_user.get('role', '').lower()
|
|
|
|
|
|
|
|
|
|
# Check if user is tenant owner or admin
|
|
|
|
|
from app.services.tenant_service import EnhancedTenantService
|
|
|
|
|
from shared.database.base import create_database_manager
|
|
|
|
|
|
|
|
|
|
tenant_service = EnhancedTenantService(create_database_manager())
|
|
|
|
|
|
|
|
|
|
# Verify tenant access and role
|
|
|
|
|
async with tenant_service.database_manager.get_session() as session:
|
|
|
|
|
await tenant_service._init_repositories(session)
|
|
|
|
|
|
|
|
|
|
# Get tenant member record
|
|
|
|
|
member = await tenant_service.member_repo.get_member_by_user_and_tenant(
|
|
|
|
|
str(user_id), str(tenant_id)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not member:
|
|
|
|
|
logger.warning("User not member of tenant",
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
tenant_id=str(tenant_id))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="Access denied: You are not a member of this tenant"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if member.role not in ['owner', 'admin']:
|
|
|
|
|
logger.warning("Insufficient permissions to cancel subscription",
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
role=member.role)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="Access denied: Only owners and admins can cancel subscriptions"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Get subscription ID from database
|
|
|
|
|
subscription = await tenant_service.subscription_repo.get_active_subscription(str(tenant_id))
|
|
|
|
|
if not subscription or not subscription.stripe_subscription_id:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="No active subscription found for this tenant"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
subscription_id = subscription.stripe_subscription_id
|
2025-10-06 15:27:01 +02:00
|
|
|
|
|
|
|
|
result = await payment_service.cancel_subscription(subscription_id)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Subscription cancelled successfully",
|
|
|
|
|
"data": {
|
|
|
|
|
"subscription_id": result.id,
|
|
|
|
|
"status": result.status
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to cancel subscription", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to cancel subscription"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@router.get(route_builder.build_base_route("subscriptions/{tenant_id}/invoices", include_tenant_prefix=False))
|
|
|
|
|
async def get_invoices(
|
|
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
|
|
|
payment_service: PaymentService = Depends(get_payment_service)
|
|
|
|
|
):
|
|
|
|
|
"""Get invoices for a tenant"""
|
|
|
|
|
|
|
|
|
|
try:
|
2025-10-24 13:05:04 +02:00
|
|
|
# Verify user has access to tenant
|
|
|
|
|
user_id = current_user.get('user_id')
|
|
|
|
|
|
|
|
|
|
from app.services.tenant_service import EnhancedTenantService
|
|
|
|
|
from shared.database.base import create_database_manager
|
|
|
|
|
|
|
|
|
|
tenant_service = EnhancedTenantService(create_database_manager())
|
|
|
|
|
|
|
|
|
|
async with tenant_service.database_manager.get_session() as session:
|
|
|
|
|
await tenant_service._init_repositories(session)
|
|
|
|
|
|
|
|
|
|
# Verify user is member of tenant
|
|
|
|
|
member = await tenant_service.member_repo.get_member_by_user_and_tenant(
|
|
|
|
|
str(user_id), str(tenant_id)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not member:
|
|
|
|
|
logger.warning("User not member of tenant",
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
tenant_id=str(tenant_id))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="Access denied: You do not have access to this tenant"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Get subscription with customer ID
|
|
|
|
|
subscription = await tenant_service.subscription_repo.get_active_subscription(str(tenant_id))
|
2025-11-19 21:01:06 +01:00
|
|
|
if not subscription:
|
|
|
|
|
# No subscription found, return empty invoices list
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
# Check if subscription has stripe customer ID
|
|
|
|
|
stripe_customer_id = getattr(subscription, 'stripe_customer_id', None)
|
|
|
|
|
if not stripe_customer_id:
|
|
|
|
|
# No Stripe customer ID, return empty invoices (demo tenants, free tier, etc.)
|
|
|
|
|
logger.debug("No Stripe customer ID for tenant",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
plan=getattr(subscription, 'plan', 'unknown'))
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
invoices = await payment_service.get_invoices(stripe_customer_id)
|
|
|
|
|
|
|
|
|
|
return invoices
|
2025-10-06 15:27:01 +02:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to get invoices", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get invoices"
|
|
|
|
|
)
|