Files
bakery-ia/services/tenant/app/api/subscription.py
2025-12-18 13:26:32 +01:00

370 lines
13 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, Tenant
from app.services.subscription_limit_service import SubscriptionLimitService
from shared.clients.stripe_client import StripeProvider
from app.core.config import settings
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
class InvoiceResponse(BaseModel):
"""Response model for an invoice"""
id: str
date: str
amount: float
currency: str
status: str
description: str | None = None
invoice_pdf: str | None = None
hosted_invoice_url: str | None = 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)
# CRITICAL: Invalidate subscription cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(tenant_id))
logger.info(
"Subscription cache invalidated after cancellation",
tenant_id=str(tenant_id)
)
except Exception as cache_error:
logger.error(
"Failed to invalidate subscription cache after cancellation",
tenant_id=str(tenant_id),
error=str(cache_error)
)
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)
# CRITICAL: Invalidate subscription cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(tenant_id))
logger.info(
"Subscription cache invalidated after reactivation",
tenant_id=str(tenant_id)
)
except Exception as cache_error:
logger.error(
"Failed to invalidate subscription cache after reactivation",
tenant_id=str(tenant_id),
error=str(cache_error)
)
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"
)
@router.get("/api/v1/subscriptions/{tenant_id}/invoices", response_model=list[InvoiceResponse])
async def get_tenant_invoices(
tenant_id: str,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get invoice history for a tenant from Stripe
"""
try:
# Verify tenant exists
query = select(Tenant).where(Tenant.id == UUID(tenant_id))
result = await db.execute(query)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
# Check if tenant has a Stripe customer ID
if not tenant.stripe_customer_id:
logger.info("no_stripe_customer_id", tenant_id=tenant_id)
return []
# Initialize Stripe provider
stripe_provider = StripeProvider(
api_key=settings.STRIPE_SECRET_KEY,
webhook_secret=settings.STRIPE_WEBHOOK_SECRET
)
# Fetch invoices from Stripe
stripe_invoices = await stripe_provider.get_invoices(tenant.stripe_customer_id)
# Transform to response format
invoices = []
for invoice in stripe_invoices:
invoices.append(InvoiceResponse(
id=invoice.id,
date=invoice.created_at.strftime('%Y-%m-%d'),
amount=invoice.amount,
currency=invoice.currency.upper(),
status=invoice.status,
description=invoice.description,
invoice_pdf=invoice.invoice_pdf,
hosted_invoice_url=invoice.hosted_invoice_url
))
logger.info("invoices_retrieved", tenant_id=tenant_id, count=len(invoices))
return invoices
except HTTPException:
raise
except Exception as e:
logger.error("get_invoices_failed", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve invoices"
)