Improve GDPR implementation
This commit is contained in:
240
services/tenant/app/api/subscription.py
Normal file
240
services/tenant/app/api/subscription.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
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"
|
||||
)
|
||||
Reference in New Issue
Block a user