Files
bakery-ia/services/tenant/app/api/subscription.py
2025-10-16 07:28:04 +02:00

241 lines
8.4 KiB
Python

"""
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"
)