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

254 lines
8.8 KiB
Python
Raw Normal View History

2025-10-16 07:28:04 +02:00
"""
Subscription management API for GDPR-compliant cancellation and reactivation
"""
2025-10-27 16:33:26 +01:00
from fastapi import APIRouter, Depends, HTTPException, status, Query
2025-10-16 07:28:04 +02:00
from pydantic import BaseModel, Field
from datetime import datetime, timezone, timedelta
from uuid import UUID
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
2025-10-27 16:33:26 +01:00
from sqlalchemy import select
2025-10-16 07:28:04 +02:00
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
2025-10-27 16:33:26 +01:00
from app.models.tenants import Subscription
from app.services.subscription_limit_service import SubscriptionLimitService
2025-10-16 07:28:04 +02:00
logger = structlog.get_logger()
router = APIRouter()
route_builder = RouteBuilder('tenant')
2025-10-27 16:33:26 +01:00
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
2025-10-16 07:28:04 +02:00
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"
)