Files
bakery-ia/services/tenant/app/api/tenant_operations.py

1071 lines
44 KiB
Python
Raw Normal View History

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
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
)
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
from shared.security import create_audit_logger, AuditSeverity, AuditAction
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")
# Initialize audit logger
2025-10-29 06:58:05 +01:00
audit_logger = create_audit_logger("tenant-service", AuditLog)
# 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")
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")
# 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)
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"""
# 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
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"),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get all tenants owned by a user - Fixed endpoint for frontend"""
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"),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get all tenant memberships for a user (for authentication service)"""
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
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:
# 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")
@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:
# 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
# ============================================================================
# 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))
Implement subscription tier redesign and component consolidation This comprehensive update includes two major improvements: ## 1. Subscription Tier Redesign (Conversion-Optimized) Frontend enhancements: - Add PlanComparisonTable component for side-by-side tier comparison - Add UsageMetricCard with predictive analytics and trend visualization - Add ROICalculator for real-time savings calculation - Add PricingComparisonModal for detailed plan comparisons - Enhance SubscriptionPricingCards with behavioral economics (Professional tier prominence) - Integrate useSubscription hook for real-time usage forecast data - Update SubscriptionPage with enhanced metrics, warnings, and CTAs - Add subscriptionAnalytics utility with 20+ conversion tracking events Backend APIs: - Add usage forecast endpoint with linear regression predictions - Add daily usage tracking for trend analysis (usage_forecast.py) - Enhance subscription error responses for conversion optimization - Update tenant operations for usage data collection Infrastructure: - Add usage tracker CronJob for daily snapshot collection - Add track_daily_usage.py script for automated usage tracking Internationalization: - Add 109 translation keys across EN/ES/EU for subscription features - Translate ROI calculator, plan comparison, and usage metrics - Update landing page translations with subscription messaging Documentation: - Add comprehensive deployment checklist - Add integration guide with code examples - Add technical implementation details (710 lines) - Add quick reference guide for common tasks - Add final integration summary Expected impact: +40% Professional tier conversions, +25% average contract value ## 2. Component Consolidation and Cleanup Purchase Order components: - Create UnifiedPurchaseOrderModal to replace redundant modals - Consolidate PurchaseOrderDetailsModal functionality into unified component - Update DashboardPage to use UnifiedPurchaseOrderModal - Update ProcurementPage to use unified approach - Add 27 new translation keys for purchase order workflows Production components: - Replace CompactProcessStageTracker with ProcessStageTracker - Update ProductionPage with enhanced stage tracking - Improve production workflow visibility UI improvements: - Enhance EditViewModal with better field handling - Improve modal reusability across domain components - Add support for approval workflows in unified modals Code cleanup: - Remove obsolete PurchaseOrderDetailsModal (620 lines) - Remove obsolete CompactProcessStageTracker (303 lines) - Net reduction: 720 lines of code while adding features - Improve maintainability with single source of truth Build verified: All changes compile successfully Total changes: 29 files, 1,183 additions, 1,903 deletions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
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"
)