""" Subscription management API for GDPR-compliant cancellation and reactivation """ from fastapi import APIRouter, Depends, HTTPException, status 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, update 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 logger = structlog.get_logger() router = APIRouter() route_builder = RouteBuilder('tenant') 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 @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: tenant_id = UUID(request.tenant_id) query = select(Subscription).where(Subscription.tenant_id == tenant_id) result = await db.execute(query) subscription = result.scalar_one_or_none() if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Subscription not found" ) if subscription.status in ['pending_cancellation', 'inactive']: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Subscription is already {subscription.status}" ) cancellation_effective_date = subscription.next_billing_date or ( datetime.now(timezone.utc) + timedelta(days=30) ) subscription.status = 'pending_cancellation' subscription.cancelled_at = datetime.now(timezone.utc) subscription.cancellation_effective_date = cancellation_effective_date await db.commit() await db.refresh(subscription) days_remaining = (cancellation_effective_date - datetime.now(timezone.utc)).days logger.info( "subscription_cancelled", tenant_id=str(tenant_id), user_id=current_user.get("sub"), effective_date=cancellation_effective_date.isoformat(), reason=request.reason[:200] if request.reason else None ) return SubscriptionCancellationResponse( success=True, message="Subscription cancelled successfully. You will have read-only access until the end of your billing period.", status="pending_cancellation", cancellation_effective_date=cancellation_effective_date.isoformat(), days_remaining=days_remaining, read_only_mode_starts=cancellation_effective_date.isoformat() ) 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: tenant_id = UUID(request.tenant_id) query = select(Subscription).where(Subscription.tenant_id == tenant_id) result = await db.execute(query) subscription = result.scalar_one_or_none() if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Subscription not found" ) if subscription.status not in ['pending_cancellation', 'inactive']: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Cannot reactivate subscription with status: {subscription.status}" ) subscription.status = 'active' subscription.plan = request.plan subscription.cancelled_at = None subscription.cancellation_effective_date = None if subscription.status == 'inactive': subscription.next_billing_date = datetime.now(timezone.utc) + timedelta(days=30) await db.commit() await db.refresh(subscription) logger.info( "subscription_reactivated", tenant_id=str(tenant_id), user_id=current_user.get("sub"), new_plan=request.plan ) return { "success": True, "message": "Subscription reactivated successfully", "status": "active", "plan": subscription.plan, "next_billing_date": subscription.next_billing_date.isoformat() if subscription.next_billing_date else None } 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: query = select(Subscription).where(Subscription.tenant_id == UUID(tenant_id)) result = await db.execute(query) subscription = result.scalar_one_or_none() if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Subscription not found" ) is_read_only = subscription.status in ['pending_cancellation', 'inactive'] days_until_inactive = None if subscription.status == 'pending_cancellation' and subscription.cancellation_effective_date: days_until_inactive = (subscription.cancellation_effective_date - datetime.now(timezone.utc)).days return SubscriptionStatusResponse( tenant_id=str(tenant_id), status=subscription.status, plan=subscription.plan, is_read_only=is_read_only, cancellation_effective_date=subscription.cancellation_effective_date.isoformat() if subscription.cancellation_effective_date else None, days_until_inactive=days_until_inactive ) 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" )