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
|
|
|
|
|
)
|
2026-01-13 22:22:38 +01:00
|
|
|
from app.core.database import get_db
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
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),
|
2026-01-13 22:22:38 +01:00
|
|
|
payment_service: PaymentService = Depends(get_payment_service),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
2025-10-06 15:27:01 +02:00
|
|
|
):
|
|
|
|
|
"""Register a new bakery/tenant with enhanced validation and features"""
|
|
|
|
|
|
|
|
|
|
try:
|
2026-01-13 22:22:38 +01:00
|
|
|
# Initialize variables to avoid UnboundLocalError
|
2025-10-17 18:14:28 +02:00
|
|
|
coupon_validation = None
|
2026-01-13 22:22:38 +01:00
|
|
|
success = None
|
|
|
|
|
discount = None
|
|
|
|
|
error = None
|
2025-10-17 18:14:28 +02:00
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
# Create bakery/tenant first
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await tenant_service.create_bakery(
|
|
|
|
|
bakery_data,
|
|
|
|
|
current_user["user_id"]
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
tenant_id = result.id
|
2025-12-18 13:26:32 +01:00
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
# 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"])
|
2025-12-18 13:26:32 +01:00
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
try:
|
|
|
|
|
# Import subscription service for linking
|
|
|
|
|
from app.services.subscription_service import SubscriptionService
|
2025-12-18 13:26:32 +01:00
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
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"]
|
2025-12-18 13:26:32 +01:00
|
|
|
)
|
|
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
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"]
|
2025-12-18 13:26:32 +01:00
|
|
|
)
|
2026-01-13 22:22:38 +01:00
|
|
|
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
|
2025-12-18 13:26:32 +01:00
|
|
|
)
|
2026-01-13 22:22:38 +01:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
)
|
2025-12-18 13:26:32 +01:00
|
|
|
|
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)
|
|
|
|
|
):
|
2025-12-27 21:30:42 +01:00
|
|
|
"""Get usage summary vs limits for a tenant (cached for 30s for performance)"""
|
2025-10-06 15:27:01 +02:00
|
|
|
|
|
|
|
|
try:
|
2025-12-27 21:30:42 +01:00
|
|
|
# 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
|
2025-10-06 15:27:01 +02:00
|
|
|
usage = await limit_service.get_usage_summary(str(tenant_id))
|
2025-12-27 21:30:42 +01:00
|
|
|
|
|
|
|
|
# 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))
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-18 13:26:32 +01:00
|
|
|
@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"
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@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
|
|
|
|
|
|
2026-01-10 21:45:37 +01:00
|
|
|
# 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))
|
|
|
|
|
|
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,
|
2026-01-10 21:45:37 +01:00
|
|
|
"validation": validation,
|
|
|
|
|
"requires_token_refresh": True # Signal to frontend
|
2025-10-06 15:27:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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],
|
2026-01-13 22:22:38 +01:00
|
|
|
plan_id: str = Query(..., description="Plan ID to subscribe to (starter, professional, enterprise)"),
|
2025-10-06 15:27:01 +02:00
|
|
|
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
|
2026-01-13 22:22:38 +01:00
|
|
|
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"""
|
|
|
|
|
|
2026-01-14 13:15:48 +01:00
|
|
|
@router.post("/api/v1/payment-customers/create")
|
2026-01-13 22:22:38 +01:00
|
|
|
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
|
2026-01-14 13:15:48 +01:00
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
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)"),
|
2025-10-06 15:27:01 +02:00
|
|
|
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,
|
2026-01-13 22:22:38 +01:00
|
|
|
coupon_code,
|
|
|
|
|
billing_interval
|
2025-10-06 15:27:01 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Registration and subscription created successfully",
|
2026-01-14 13:15:48 +01:00
|
|
|
**result
|
2025-10-06 15:27:01 +02:00
|
|
|
}
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
@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",
|
2026-01-14 13:15:48 +01:00
|
|
|
**result
|
2026-01-13 22:22:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-10 21:45:37 +01:00
|
|
|
|
|
|
|
|
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
|