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"
|
||||
)
|
||||
103
services/tenant/app/jobs/subscription_downgrade.py
Normal file
103
services/tenant/app/jobs/subscription_downgrade.py
Normal 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())
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user