Improve GDPR implementation

This commit is contained in:
Urtzi Alfaro
2025-10-16 07:28:04 +02:00
parent dbb48d8e2c
commit b6cb800758
37 changed files with 4876 additions and 307 deletions

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

View File

@@ -0,0 +1,103 @@
"""
Background job to process subscription downgrades at period end
Runs periodically to check for subscriptions with:
- status = 'pending_cancellation'
- cancellation_effective_date <= now()
Converts them to 'inactive' status
"""
import structlog
import asyncio
from datetime import datetime, timezone
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_async_session_factory
from app.models.tenants import Subscription
logger = structlog.get_logger()
async def process_pending_cancellations():
"""
Process all subscriptions that have reached their cancellation_effective_date
"""
async_session_factory = get_async_session_factory()
async with async_session_factory() as session:
try:
query = select(Subscription).where(
Subscription.status == 'pending_cancellation',
Subscription.cancellation_effective_date <= datetime.now(timezone.utc)
)
result = await session.execute(query)
subscriptions_to_downgrade = result.scalars().all()
downgraded_count = 0
for subscription in subscriptions_to_downgrade:
subscription.status = 'inactive'
subscription.plan = 'free'
subscription.monthly_price = 0.0
logger.info(
"subscription_downgraded_to_inactive",
tenant_id=str(subscription.tenant_id),
previous_plan=subscription.plan,
cancellation_effective_date=subscription.cancellation_effective_date.isoformat()
)
downgraded_count += 1
if downgraded_count > 0:
await session.commit()
logger.info(
"subscriptions_downgraded",
count=downgraded_count
)
else:
logger.debug("no_subscriptions_to_downgrade")
return downgraded_count
except Exception as e:
logger.error(
"subscription_downgrade_job_failed",
error=str(e)
)
await session.rollback()
raise
async def run_subscription_downgrade_job():
"""
Main entry point for the subscription downgrade job
Runs in a loop with configurable interval
"""
interval_seconds = 3600 # Run every hour
logger.info("subscription_downgrade_job_started", interval_seconds=interval_seconds)
while True:
try:
downgraded_count = await process_pending_cancellations()
logger.info(
"subscription_downgrade_job_completed",
downgraded_count=downgraded_count
)
except Exception as e:
logger.error(
"subscription_downgrade_job_error",
error=str(e)
)
await asyncio.sleep(interval_seconds)
if __name__ == "__main__":
asyncio.run(run_subscription_downgrade_job())

View File

@@ -7,7 +7,7 @@ from fastapi import FastAPI
from sqlalchemy import text
from app.core.config import settings
from app.core.database import database_manager
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans, subscription
from shared.service_base import StandardFastAPIService
@@ -112,6 +112,7 @@ service.setup_custom_endpoints()
# Include routers
service.add_router(plans.router, tags=["subscription-plans"]) # Public endpoint
service.add_router(subscription.router, tags=["subscription"])
service.add_router(tenants.router, tags=["tenants"])
service.add_router(tenant_members.router, tags=["tenant-members"])
service.add_router(tenant_operations.router, tags=["tenant-operations"])

View File

@@ -106,13 +106,17 @@ class Subscription(Base):
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
plan = Column(String(50), default="starter") # starter, professional, enterprise
status = Column(String(50), default="active") # active, suspended, cancelled
status = Column(String(50), default="active") # active, pending_cancellation, inactive, suspended
# Billing
monthly_price = Column(Float, default=0.0)
billing_cycle = Column(String(20), default="monthly") # monthly, yearly
next_billing_date = Column(DateTime(timezone=True))
trial_ends_at = Column(DateTime(timezone=True))
cancelled_at = Column(DateTime(timezone=True), nullable=True)
cancellation_effective_date = Column(DateTime(timezone=True), nullable=True)
stripe_subscription_id = Column(String(255), nullable=True)
stripe_customer_id = Column(String(255), nullable=True)
# Limits
max_users = Column(Integer, default=5)

View File

@@ -0,0 +1,32 @@
"""add_subscription_cancellation_fields
Revision ID: 20251016_0000
Revises: 4e1ddc13dd0f
Create Date: 2025-10-16 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '20251016_0000'
down_revision = '4e1ddc13dd0f'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add new columns to subscriptions table
op.add_column('subscriptions', sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('subscriptions', sa.Column('cancellation_effective_date', sa.DateTime(timezone=True), nullable=True))
op.add_column('subscriptions', sa.Column('stripe_subscription_id', sa.String(length=255), nullable=True))
op.add_column('subscriptions', sa.Column('stripe_customer_id', sa.String(length=255), nullable=True))
def downgrade() -> None:
# Remove columns
op.drop_column('subscriptions', 'stripe_customer_id')
op.drop_column('subscriptions', 'stripe_subscription_id')
op.drop_column('subscriptions', 'cancellation_effective_date')
op.drop_column('subscriptions', 'cancelled_at')