254 lines
8.8 KiB
Python
254 lines
8.8 KiB
Python
"""
|
|
Subscription management API for GDPR-compliant cancellation and reactivation
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|
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
|
|
|
|
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
|
|
from app.services.subscription_limit_service import SubscriptionLimitService
|
|
|
|
logger = structlog.get_logger()
|
|
router = APIRouter()
|
|
route_builder = RouteBuilder('tenant')
|
|
|
|
|
|
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
|
|
|
|
|
|
@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"
|
|
)
|