""" Subscription management API for GDPR-compliant cancellation and reactivation """ from fastapi import APIRouter, Depends, HTTPException, status, Query, Path from pydantic import BaseModel, Field from datetime import datetime, timezone, timedelta from uuid import UUID import structlog from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select import json from shared.auth.decorators import get_current_user_dep, require_admin_role_dep from shared.routing import RouteBuilder from app.core.database import get_db from app.models.tenants import Subscription, Tenant from app.services.subscription_limit_service import SubscriptionLimitService from app.services.subscription_service import SubscriptionService from shared.clients.stripe_client import StripeProvider from app.core.config import settings from shared.database.exceptions import DatabaseError, ValidationError from shared.redis_utils import get_redis_client from shared.database.base import create_database_manager import shared.redis_utils # Global Redis client for caching _redis_client = None logger = structlog.get_logger() router = APIRouter() route_builder = RouteBuilder('tenant') async def get_tenant_redis_client(): """Get or create Redis client""" global _redis_client try: if _redis_client is None: from app.core.config import settings _redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL) logger.info("Redis client initialized using shared utilities") return _redis_client except Exception as e: logger.warning("Failed to initialize Redis client, service will work with limited functionality", error=str(e)) return None def get_subscription_limit_service(): """ Dependency injection for SubscriptionLimitService """ try: database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") return SubscriptionLimitService(database_manager) except Exception as e: logger.error("Failed to create subscription limit service", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to initialize subscription limit service" ) class QuotaCheckResponse(BaseModel): """Response for quota limit checks""" allowed: bool message: str limit: int current_count: int max_allowed: int reason: str requested_amount: int available_amount: int class SubscriptionCancellationRequest(BaseModel): """Request model for subscription cancellation""" tenant_id: str = Field(..., description="Tenant ID to cancel subscription for") reason: str = Field(default="", description="Optional cancellation reason") class SubscriptionCancellationResponse(BaseModel): """Response for subscription cancellation""" success: bool message: str status: str cancellation_effective_date: str days_remaining: int read_only_mode_starts: str class SubscriptionReactivationRequest(BaseModel): """Request model for subscription reactivation""" tenant_id: str = Field(..., description="Tenant ID to reactivate subscription for") plan: str = Field(default="starter", description="Plan to reactivate with") class SubscriptionStatusResponse(BaseModel): """Response for subscription status check""" tenant_id: str status: str plan: str is_read_only: bool cancellation_effective_date: str | None days_until_inactive: int | None class InvoiceResponse(BaseModel): """Response model for an invoice""" id: str date: str amount: float currency: str status: str description: str | None = None invoice_pdf: str | None = None hosted_invoice_url: str | None = None @router.post("/api/v1/subscriptions/cancel", response_model=SubscriptionCancellationResponse) async def cancel_subscription( request: SubscriptionCancellationRequest, current_user: dict = Depends(require_admin_role_dep), db: AsyncSession = Depends(get_db) ): """ Cancel subscription - Downgrade to read-only mode Flow: 1. Set status to 'pending_cancellation' 2. Calculate cancellation_effective_date (end of billing period) 3. User keeps access until effective date 4. Background job converts to 'inactive' at effective date 5. Gateway enforces read-only mode for 'pending_cancellation' and 'inactive' statuses """ try: # Use service layer instead of direct database access subscription_service = SubscriptionService(db) result = await subscription_service.cancel_subscription( request.tenant_id, request.reason ) logger.info( "subscription_cancelled", tenant_id=request.tenant_id, user_id=current_user.get("sub"), effective_date=result["cancellation_effective_date"], reason=request.reason[:200] if request.reason else None ) return SubscriptionCancellationResponse( success=result["success"], message=result["message"], status=result["status"], cancellation_effective_date=result["cancellation_effective_date"], days_remaining=result["days_remaining"], read_only_mode_starts=result["read_only_mode_starts"] ) except ValidationError as ve: logger.error("subscription_cancellation_validation_failed", error=str(ve), tenant_id=request.tenant_id) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(ve) ) except DatabaseError as de: logger.error("subscription_cancellation_failed", error=str(de), tenant_id=request.tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to cancel subscription" ) except HTTPException: raise except Exception as e: logger.error("subscription_cancellation_failed", error=str(e), tenant_id=request.tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to cancel subscription" ) @router.post("/api/v1/subscriptions/reactivate") async def reactivate_subscription( request: SubscriptionReactivationRequest, current_user: dict = Depends(require_admin_role_dep), db: AsyncSession = Depends(get_db) ): """ Reactivate a cancelled or inactive subscription Can reactivate from: - pending_cancellation (before effective date) - inactive (after effective date) """ try: # Use service layer instead of direct database access subscription_service = SubscriptionService(db) result = await subscription_service.reactivate_subscription( request.tenant_id, request.plan ) logger.info( "subscription_reactivated", tenant_id=request.tenant_id, user_id=current_user.get("sub"), new_plan=request.plan ) return { "success": result["success"], "message": result["message"], "status": result["status"], "plan": result["plan"], "next_billing_date": result["next_billing_date"] } except ValidationError as ve: logger.error("subscription_reactivation_validation_failed", error=str(ve), tenant_id=request.tenant_id) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(ve) ) except DatabaseError as de: logger.error("subscription_reactivation_failed", error=str(de), tenant_id=request.tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reactivate subscription" ) except HTTPException: raise except Exception as e: logger.error("subscription_reactivation_failed", error=str(e), tenant_id=request.tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reactivate subscription" ) @router.get("/api/v1/subscriptions/{tenant_id}/status", response_model=SubscriptionStatusResponse) async def get_subscription_status( tenant_id: str, current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Get current subscription status including read-only mode info """ try: # Use service layer instead of direct database access subscription_service = SubscriptionService(db) result = await subscription_service.get_subscription_status(tenant_id) return SubscriptionStatusResponse( tenant_id=result["tenant_id"], status=result["status"], plan=result["plan"], is_read_only=result["is_read_only"], cancellation_effective_date=result["cancellation_effective_date"], days_until_inactive=result["days_until_inactive"] ) except ValidationError as ve: logger.error("get_subscription_status_validation_failed", error=str(ve), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(ve) ) except DatabaseError as de: logger.error("get_subscription_status_failed", error=str(de), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get subscription status" ) except HTTPException: raise except Exception as e: logger.error("get_subscription_status_failed", error=str(e), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get subscription status" ) @router.get("/api/v1/subscriptions/{tenant_id}/invoices", response_model=list[InvoiceResponse]) async def get_tenant_invoices( tenant_id: str, current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Get invoice history for a tenant from Stripe """ try: # Use service layer instead of direct database access subscription_service = SubscriptionService(db) invoices_data = await subscription_service.get_tenant_invoices(tenant_id) # Transform to response format invoices = [] for invoice_data in invoices_data: invoices.append(InvoiceResponse( id=invoice_data["id"], date=invoice_data["date"], amount=invoice_data["amount"], currency=invoice_data["currency"], status=invoice_data["status"], description=invoice_data["description"], invoice_pdf=invoice_data["invoice_pdf"], hosted_invoice_url=invoice_data["hosted_invoice_url"] )) logger.info("invoices_retrieved", tenant_id=tenant_id, count=len(invoices)) return invoices except ValidationError as ve: logger.error("get_invoices_validation_failed", error=str(ve), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(ve) ) except DatabaseError as de: logger.error("get_invoices_failed", error=str(de), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve invoices" ) except HTTPException: raise except Exception as e: logger.error("get_invoices_failed", error=str(e), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve invoices" ) # ============================================================================ # ADDITIONAL SUBSCRIPTION ENDPOINTS (Consolidated from tenant_operations.py) # ============================================================================ @router.get("/api/v1/subscriptions/{tenant_id}/tier") async def get_tenant_subscription_tier_fast( tenant_id: str = 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). """ 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("/api/v1/subscriptions/{tenant_id}/active") async def get_tenant_active_subscription( tenant_id: str = Path(..., description="Tenant ID"), redis_client = Depends(get_tenant_redis_client) ): """ Get full active subscription with caching Returns complete subscription details with 10-minute Redis cache. """ try: from app.services.subscription_cache import get_subscription_cache_service cache_service = get_subscription_cache_service(redis_client) subscription = await cache_service.get_tenant_subscription_cached(str(tenant_id)) if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No active subscription found" ) return subscription except HTTPException: raise except Exception as e: logger.error("Failed to get active subscription", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get active subscription" ) @router.get("/api/v1/subscriptions/{tenant_id}/limits") async def get_subscription_limits( tenant_id: str = Path(..., description="Tenant ID"), current_user: dict = 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("/api/v1/subscriptions/{tenant_id}/usage") async def get_usage_summary( tenant_id: str = Path(..., description="Tenant ID"), current_user: dict = Depends(get_current_user_dep), limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) ): """Get usage summary vs limits for a tenant (cached for 30s for performance)""" try: # Try to get from cache first (30s TTL) redis_client = await get_redis_client() if redis_client: cache_key = f"usage_summary:{tenant_id}" cached = await redis_client.get(cache_key) if cached: logger.debug("Usage summary cache hit", tenant_id=str(tenant_id)) return json.loads(cached) # Cache miss - fetch fresh data usage = await limit_service.get_usage_summary(str(tenant_id)) # Store in cache with 30s TTL if redis_client: await redis_client.setex(cache_key, 30, json.dumps(usage)) logger.debug("Usage summary cached", tenant_id=str(tenant_id)) return usage except Exception as e: logger.error("Failed to get usage summary", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get usage summary" ) @router.get("/api/v1/subscriptions/{tenant_id}/can-add-location") async def can_add_location( tenant_id: str = Path(..., description="Tenant ID"), current_user: dict = 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("/api/v1/subscriptions/{tenant_id}/can-add-product") async def can_add_product( tenant_id: str = Path(..., description="Tenant ID"), current_user: dict = 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("/api/v1/subscriptions/{tenant_id}/can-add-user") async def can_add_user( tenant_id: str = Path(..., description="Tenant ID"), current_user: dict = 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("/api/v1/subscriptions/{tenant_id}/features/{feature}") async def has_feature( tenant_id: str = Path(..., description="Tenant ID"), feature: str = Path(..., description="Feature name"), current_user: dict = 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("/api/v1/subscriptions/{tenant_id}/validate-upgrade/{new_plan}") async def validate_plan_upgrade( tenant_id: str = Path(..., description="Tenant ID"), new_plan: str = Path(..., description="New plan name"), current_user: dict = 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("/api/v1/subscriptions/{tenant_id}/upgrade") async def upgrade_subscription_plan( tenant_id: str = Path(..., description="Tenant ID"), new_plan: str = Query(..., description="New plan name"), current_user: dict = Depends(get_current_user_dep), limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service), db: AsyncSession = Depends(get_db) ): """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") ) # Use SubscriptionService for the upgrade subscription_service = SubscriptionService(db) # Get current subscription current_subscription = await subscription_service.get_subscription_by_tenant_id(tenant_id) if not current_subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No active subscription found for this tenant" ) # Update the subscription plan using service layer # Note: This should be enhanced in SubscriptionService to handle plan upgrades # For now, we'll use the repository directly but this should be moved to service layer from app.repositories.subscription_repository import SubscriptionRepository from app.models.tenants import Subscription as SubscriptionModel subscription_repo = SubscriptionRepository(SubscriptionModel, db) updated_subscription = await subscription_repo.update_subscription_plan( str(current_subscription.id), new_plan ) # 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 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)) # SECURITY: Invalidate all existing tokens for this tenant # Forces users to re-authenticate and get new JWT with updated tier try: redis_client = await get_redis_client() if redis_client: changed_timestamp = 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 token_error: logger.error("Failed to invalidate tenant tokens after upgrade", tenant_id=str(tenant_id), error=str(token_error)) # 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": current_subscription.plan, "new_tier": new_plan, "action": "upgrade" } ) logger.info("Published subscription change event", tenant_id=str(tenant_id), event_type="subscription.changed") except Exception as event_error: logger.error("Failed to publish subscription change event", tenant_id=str(tenant_id), error=str(event_error)) return { "success": True, "message": f"Plan successfully upgraded to {new_plan}", "old_plan": current_subscription.plan, "new_plan": new_plan, "new_monthly_price": updated_subscription.monthly_price, "validation": validation, "requires_token_refresh": True # Signal to frontend } except HTTPException: raise except Exception as e: logger.error("Failed to upgrade subscription plan", tenant_id=str(tenant_id), new_plan=new_plan, error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to upgrade subscription plan" ) @router.post("/api/v1/subscriptions/register-with-subscription") async def register_with_subscription( user_data: dict = Depends(get_current_user_dep), 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"), db: AsyncSession = Depends(get_db) ): """Process user registration with subscription creation""" try: # Use SubscriptionService for registration subscription_service = SubscriptionService(db) result = await subscription_service.create_subscription( user_data.get('tenant_id'), plan_id, payment_method_id, 14 if use_trial else None ) 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("/api/v1/subscriptions/{tenant_id}/update-payment-method") async def update_payment_method( tenant_id: str = Path(..., description="Tenant ID"), payment_method_id: str = Query(..., description="New payment method ID"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Update the default payment method for a subscription This endpoint allows users to change their payment method through the UI. It updates the default payment method in Stripe and returns the updated payment method information. """ try: # Use SubscriptionService to get subscription and update payment method subscription_service = SubscriptionService(db) # Get current subscription subscription = await subscription_service.get_subscription_by_tenant_id(tenant_id) if not subscription: raise ValidationError(f"Subscription not found for tenant {tenant_id}") if not subscription.stripe_customer_id: raise ValidationError(f"Tenant {tenant_id} does not have a Stripe customer ID") # Update payment method via PaymentService payment_result = await subscription_service.payment_service.update_payment_method( subscription.stripe_customer_id, payment_method_id ) logger.info("Payment method updated successfully", tenant_id=tenant_id, payment_method_id=payment_method_id, user_id=current_user.get("user_id")) return { "success": True, "message": "Payment method updated successfully", "payment_method_id": payment_result.id, "brand": getattr(payment_result, 'brand', 'unknown'), "last4": getattr(payment_result, 'last4', '0000'), "exp_month": getattr(payment_result, 'exp_month', None), "exp_year": getattr(payment_result, 'exp_year', None) } except ValidationError as ve: logger.error("update_payment_method_validation_failed", error=str(ve), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(ve) ) except DatabaseError as de: logger.error("update_payment_method_failed", error=str(de), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update payment method" ) except Exception as e: logger.error("update_payment_method_unexpected_error", error=str(e), tenant_id=tenant_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating payment method" )