2025-10-16 07:28:04 +02:00
|
|
|
"""
|
|
|
|
|
Subscription management API for GDPR-compliant cancellation and reactivation
|
|
|
|
|
"""
|
|
|
|
|
|
2026-01-11 21:40:04 +01:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
|
2025-10-16 07:28:04 +02:00
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
import structlog
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2025-10-27 16:33:26 +01:00
|
|
|
from sqlalchemy import select
|
2026-01-11 21:40:04 +01:00
|
|
|
import json
|
2025-10-16 07:28:04 +02:00
|
|
|
|
|
|
|
|
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
|
2025-10-31 11:54:19 +01:00
|
|
|
from app.models.tenants import Subscription, Tenant
|
2025-10-27 16:33:26 +01:00
|
|
|
from app.services.subscription_limit_service import SubscriptionLimitService
|
2026-01-11 21:40:04 +01:00
|
|
|
from app.services.subscription_service import SubscriptionService
|
2025-10-31 11:54:19 +01:00
|
|
|
from shared.clients.stripe_client import StripeProvider
|
|
|
|
|
from app.core.config import settings
|
2026-01-11 21:40:04 +01:00
|
|
|
from shared.database.exceptions import DatabaseError, ValidationError
|
|
|
|
|
from shared.redis_utils import get_redis_client
|
|
|
|
|
from shared.database.base import create_database_manager
|
|
|
|
|
import shared.redis_utils
|
|
|
|
|
|
|
|
|
|
# Global Redis client for caching
|
|
|
|
|
_redis_client = None
|
2025-10-16 07:28:04 +02:00
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
route_builder = RouteBuilder('tenant')
|
|
|
|
|
|
|
|
|
|
|
2026-01-11 21:40:04 +01:00
|
|
|
async def get_tenant_redis_client():
|
|
|
|
|
"""Get or create Redis client"""
|
|
|
|
|
global _redis_client
|
|
|
|
|
try:
|
|
|
|
|
if _redis_client is None:
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
_redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
|
|
|
|
|
logger.info("Redis client initialized using shared utilities")
|
|
|
|
|
return _redis_client
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("Failed to initialize Redis client, service will work with limited functionality", error=str(e))
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_subscription_limit_service():
|
|
|
|
|
"""
|
|
|
|
|
Dependency injection for SubscriptionLimitService
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
|
|
|
|
return SubscriptionLimitService(database_manager)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to create subscription limit service", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to initialize subscription limit service"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-27 16:33:26 +01:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2025-10-16 07:28:04 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2025-10-31 11:54:19 +01:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2025-10-16 07:28:04 +02:00
|
|
|
@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:
|
2026-01-11 21:40:04 +01:00
|
|
|
# Use service layer instead of direct database access
|
|
|
|
|
subscription_service = SubscriptionService(db)
|
|
|
|
|
result = await subscription_service.cancel_subscription(
|
|
|
|
|
request.tenant_id,
|
|
|
|
|
request.reason
|
2025-10-16 07:28:04 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"subscription_cancelled",
|
2026-01-11 21:40:04 +01:00
|
|
|
tenant_id=request.tenant_id,
|
2025-10-16 07:28:04 +02:00
|
|
|
user_id=current_user.get("sub"),
|
2026-01-11 21:40:04 +01:00
|
|
|
effective_date=result["cancellation_effective_date"],
|
2025-10-16 07:28:04 +02:00
|
|
|
reason=request.reason[:200] if request.reason else None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return SubscriptionCancellationResponse(
|
2026-01-11 21:40:04 +01:00
|
|
|
success=result["success"],
|
|
|
|
|
message=result["message"],
|
|
|
|
|
status=result["status"],
|
|
|
|
|
cancellation_effective_date=result["cancellation_effective_date"],
|
|
|
|
|
days_remaining=result["days_remaining"],
|
|
|
|
|
read_only_mode_starts=result["read_only_mode_starts"]
|
2025-10-16 07:28:04 +02:00
|
|
|
)
|
|
|
|
|
|
2026-01-11 21:40:04 +01:00
|
|
|
except ValidationError as ve:
|
|
|
|
|
logger.error("subscription_cancellation_validation_failed",
|
|
|
|
|
error=str(ve), tenant_id=request.tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(ve)
|
|
|
|
|
)
|
|
|
|
|
except DatabaseError as de:
|
|
|
|
|
logger.error("subscription_cancellation_failed", error=str(de), tenant_id=request.tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to cancel subscription"
|
|
|
|
|
)
|
2025-10-16 07:28:04 +02:00
|
|
|
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:
|
2026-01-11 21:40:04 +01:00
|
|
|
# Use service layer instead of direct database access
|
|
|
|
|
subscription_service = SubscriptionService(db)
|
|
|
|
|
result = await subscription_service.reactivate_subscription(
|
|
|
|
|
request.tenant_id,
|
|
|
|
|
request.plan
|
|
|
|
|
)
|
2025-12-18 13:26:32 +01:00
|
|
|
|
2025-10-16 07:28:04 +02:00
|
|
|
logger.info(
|
|
|
|
|
"subscription_reactivated",
|
2026-01-11 21:40:04 +01:00
|
|
|
tenant_id=request.tenant_id,
|
2025-10-16 07:28:04 +02:00
|
|
|
user_id=current_user.get("sub"),
|
|
|
|
|
new_plan=request.plan
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
2026-01-11 21:40:04 +01:00
|
|
|
"success": result["success"],
|
|
|
|
|
"message": result["message"],
|
|
|
|
|
"status": result["status"],
|
|
|
|
|
"plan": result["plan"],
|
|
|
|
|
"next_billing_date": result["next_billing_date"]
|
2025-10-16 07:28:04 +02:00
|
|
|
}
|
|
|
|
|
|
2026-01-11 21:40:04 +01:00
|
|
|
except ValidationError as ve:
|
|
|
|
|
logger.error("subscription_reactivation_validation_failed",
|
|
|
|
|
error=str(ve), tenant_id=request.tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(ve)
|
|
|
|
|
)
|
|
|
|
|
except DatabaseError as de:
|
|
|
|
|
logger.error("subscription_reactivation_failed", error=str(de), tenant_id=request.tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to reactivate subscription"
|
|
|
|
|
)
|
2025-10-16 07:28:04 +02:00
|
|
|
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:
|
2026-01-11 21:40:04 +01:00
|
|
|
# Use service layer instead of direct database access
|
|
|
|
|
subscription_service = SubscriptionService(db)
|
|
|
|
|
result = await subscription_service.get_subscription_status(tenant_id)
|
2025-10-16 07:28:04 +02:00
|
|
|
|
|
|
|
|
return SubscriptionStatusResponse(
|
2026-01-11 21:40:04 +01:00
|
|
|
tenant_id=result["tenant_id"],
|
|
|
|
|
status=result["status"],
|
|
|
|
|
plan=result["plan"],
|
|
|
|
|
is_read_only=result["is_read_only"],
|
|
|
|
|
cancellation_effective_date=result["cancellation_effective_date"],
|
|
|
|
|
days_until_inactive=result["days_until_inactive"]
|
2025-10-16 07:28:04 +02:00
|
|
|
)
|
|
|
|
|
|
2026-01-11 21:40:04 +01:00
|
|
|
except ValidationError as ve:
|
|
|
|
|
logger.error("get_subscription_status_validation_failed",
|
|
|
|
|
error=str(ve), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(ve)
|
|
|
|
|
)
|
|
|
|
|
except DatabaseError as de:
|
|
|
|
|
logger.error("get_subscription_status_failed", error=str(de), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get subscription status"
|
|
|
|
|
)
|
2025-10-16 07:28:04 +02:00
|
|
|
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"
|
|
|
|
|
)
|
2025-10-31 11:54:19 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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:
|
2026-01-11 21:40:04 +01:00
|
|
|
# Use service layer instead of direct database access
|
|
|
|
|
subscription_service = SubscriptionService(db)
|
|
|
|
|
invoices_data = await subscription_service.get_tenant_invoices(tenant_id)
|
2025-10-31 11:54:19 +01:00
|
|
|
|
|
|
|
|
# Transform to response format
|
|
|
|
|
invoices = []
|
2026-01-11 21:40:04 +01:00
|
|
|
for invoice_data in invoices_data:
|
2025-10-31 11:54:19 +01:00
|
|
|
invoices.append(InvoiceResponse(
|
2026-01-11 21:40:04 +01:00
|
|
|
id=invoice_data["id"],
|
|
|
|
|
date=invoice_data["date"],
|
|
|
|
|
amount=invoice_data["amount"],
|
|
|
|
|
currency=invoice_data["currency"],
|
|
|
|
|
status=invoice_data["status"],
|
|
|
|
|
description=invoice_data["description"],
|
|
|
|
|
invoice_pdf=invoice_data["invoice_pdf"],
|
|
|
|
|
hosted_invoice_url=invoice_data["hosted_invoice_url"]
|
2025-10-31 11:54:19 +01:00
|
|
|
))
|
|
|
|
|
|
|
|
|
|
logger.info("invoices_retrieved", tenant_id=tenant_id, count=len(invoices))
|
|
|
|
|
return invoices
|
|
|
|
|
|
2026-01-11 21:40:04 +01:00
|
|
|
except ValidationError as ve:
|
|
|
|
|
logger.error("get_invoices_validation_failed",
|
|
|
|
|
error=str(ve), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(ve)
|
|
|
|
|
)
|
|
|
|
|
except DatabaseError as de:
|
|
|
|
|
logger.error("get_invoices_failed", error=str(de), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to retrieve invoices"
|
|
|
|
|
)
|
2025-10-31 11:54:19 +01:00
|
|
|
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"
|
|
|
|
|
)
|
2026-01-11 21:40:04 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# ADDITIONAL SUBSCRIPTION ENDPOINTS (Consolidated from tenant_operations.py)
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/api/v1/subscriptions/{tenant_id}/tier")
|
|
|
|
|
async def get_tenant_subscription_tier_fast(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
redis_client = Depends(get_tenant_redis_client)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Fast cached lookup for tenant subscription tier
|
|
|
|
|
|
|
|
|
|
This endpoint is optimized for high-frequency access (e.g., from gateway middleware)
|
|
|
|
|
with Redis caching (10-minute TTL).
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
from app.services.subscription_cache import get_subscription_cache_service
|
|
|
|
|
|
|
|
|
|
cache_service = get_subscription_cache_service(redis_client)
|
|
|
|
|
tier = await cache_service.get_tenant_tier_cached(str(tenant_id))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"tenant_id": str(tenant_id),
|
|
|
|
|
"tier": tier,
|
|
|
|
|
"cached": True
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to get subscription tier",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get subscription tier"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/v1/subscriptions/{tenant_id}/active")
|
|
|
|
|
async def get_tenant_active_subscription(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
redis_client = Depends(get_tenant_redis_client)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Get full active subscription with caching
|
|
|
|
|
|
|
|
|
|
Returns complete subscription details with 10-minute Redis cache.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
from app.services.subscription_cache import get_subscription_cache_service
|
|
|
|
|
|
|
|
|
|
cache_service = get_subscription_cache_service(redis_client)
|
|
|
|
|
subscription = await cache_service.get_tenant_subscription_cached(str(tenant_id))
|
|
|
|
|
|
|
|
|
|
if not subscription:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="No active subscription found"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return subscription
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to get active subscription",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get active subscription"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/v1/subscriptions/{tenant_id}/limits")
|
|
|
|
|
async def get_subscription_limits(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
|
|
|
|
):
|
|
|
|
|
"""Get current subscription limits for a tenant"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
limits = await limit_service.get_tenant_subscription_limits(str(tenant_id))
|
|
|
|
|
return limits
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to get subscription limits",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get subscription limits"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/v1/subscriptions/{tenant_id}/usage")
|
|
|
|
|
async def get_usage_summary(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
|
|
|
|
):
|
|
|
|
|
"""Get usage summary vs limits for a tenant (cached for 30s for performance)"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Try to get from cache first (30s TTL)
|
|
|
|
|
redis_client = await get_redis_client()
|
|
|
|
|
|
|
|
|
|
if redis_client:
|
|
|
|
|
cache_key = f"usage_summary:{tenant_id}"
|
|
|
|
|
cached = await redis_client.get(cache_key)
|
|
|
|
|
if cached:
|
|
|
|
|
logger.debug("Usage summary cache hit", tenant_id=str(tenant_id))
|
|
|
|
|
return json.loads(cached)
|
|
|
|
|
|
|
|
|
|
# Cache miss - fetch fresh data
|
|
|
|
|
usage = await limit_service.get_usage_summary(str(tenant_id))
|
|
|
|
|
|
|
|
|
|
# Store in cache with 30s TTL
|
|
|
|
|
if redis_client:
|
|
|
|
|
await redis_client.setex(cache_key, 30, json.dumps(usage))
|
|
|
|
|
logger.debug("Usage summary cached", tenant_id=str(tenant_id))
|
|
|
|
|
|
|
|
|
|
return usage
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to get usage summary",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get usage summary"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/v1/subscriptions/{tenant_id}/can-add-location")
|
|
|
|
|
async def can_add_location(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
|
|
|
|
):
|
|
|
|
|
"""Check if tenant can add another location"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = await limit_service.can_add_location(str(tenant_id))
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to check location limits",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to check location limits"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/v1/subscriptions/{tenant_id}/can-add-product")
|
|
|
|
|
async def can_add_product(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
|
|
|
|
):
|
|
|
|
|
"""Check if tenant can add another product"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = await limit_service.can_add_product(str(tenant_id))
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to check product limits",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to check product limits"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/v1/subscriptions/{tenant_id}/can-add-user")
|
|
|
|
|
async def can_add_user(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
|
|
|
|
):
|
|
|
|
|
"""Check if tenant can add another user/member"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = await limit_service.can_add_user(str(tenant_id))
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to check user limits",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to check user limits"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/v1/subscriptions/{tenant_id}/features/{feature}")
|
|
|
|
|
async def has_feature(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
feature: str = Path(..., description="Feature name"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
|
|
|
|
):
|
|
|
|
|
"""Check if tenant has access to a specific feature"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = await limit_service.has_feature(str(tenant_id), feature)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to check feature access",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
feature=feature,
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to check feature access"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/v1/subscriptions/{tenant_id}/validate-upgrade/{new_plan}")
|
|
|
|
|
async def validate_plan_upgrade(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
new_plan: str = Path(..., description="New plan name"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service)
|
|
|
|
|
):
|
|
|
|
|
"""Validate if tenant can upgrade to a new plan"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to validate plan upgrade",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
new_plan=new_plan,
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to validate plan upgrade"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/api/v1/subscriptions/{tenant_id}/upgrade")
|
|
|
|
|
async def upgrade_subscription_plan(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
new_plan: str = Query(..., description="New plan name"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Upgrade subscription plan for a tenant"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# First validate the upgrade
|
|
|
|
|
validation = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan)
|
|
|
|
|
if not validation.get("can_upgrade", False):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=validation.get("reason", "Cannot upgrade to this plan")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Use SubscriptionService for the upgrade
|
|
|
|
|
subscription_service = SubscriptionService(db)
|
|
|
|
|
|
|
|
|
|
# Get current subscription
|
|
|
|
|
current_subscription = await subscription_service.get_subscription_by_tenant_id(tenant_id)
|
|
|
|
|
if not current_subscription:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="No active subscription found for this tenant"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Update the subscription plan using service layer
|
|
|
|
|
# Note: This should be enhanced in SubscriptionService to handle plan upgrades
|
|
|
|
|
# For now, we'll use the repository directly but this should be moved to service layer
|
|
|
|
|
from app.repositories.subscription_repository import SubscriptionRepository
|
|
|
|
|
from app.models.tenants import Subscription as SubscriptionModel
|
|
|
|
|
|
|
|
|
|
subscription_repo = SubscriptionRepository(SubscriptionModel, db)
|
|
|
|
|
updated_subscription = await subscription_repo.update_subscription_plan(
|
|
|
|
|
str(current_subscription.id),
|
|
|
|
|
new_plan
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Invalidate subscription cache to ensure immediate availability of new tier
|
|
|
|
|
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 upgrade",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
new_plan=new_plan)
|
|
|
|
|
except Exception as cache_error:
|
|
|
|
|
logger.error("Failed to invalidate subscription cache after upgrade",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(cache_error))
|
|
|
|
|
|
|
|
|
|
# SECURITY: Invalidate all existing tokens for this tenant
|
|
|
|
|
# Forces users to re-authenticate and get new JWT with updated tier
|
|
|
|
|
try:
|
|
|
|
|
redis_client = await get_redis_client()
|
|
|
|
|
if redis_client:
|
|
|
|
|
changed_timestamp = datetime.now(timezone.utc).timestamp()
|
|
|
|
|
await redis_client.set(
|
|
|
|
|
f"tenant:{tenant_id}:subscription_changed_at",
|
|
|
|
|
str(changed_timestamp),
|
|
|
|
|
ex=86400 # 24 hour TTL
|
|
|
|
|
)
|
|
|
|
|
logger.info("Set subscription change timestamp for token invalidation",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
timestamp=changed_timestamp)
|
|
|
|
|
except Exception as token_error:
|
|
|
|
|
logger.error("Failed to invalidate tenant tokens after upgrade",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(token_error))
|
|
|
|
|
|
|
|
|
|
# Also publish event for real-time notification
|
|
|
|
|
try:
|
|
|
|
|
from shared.messaging import UnifiedEventPublisher
|
|
|
|
|
event_publisher = UnifiedEventPublisher()
|
|
|
|
|
await event_publisher.publish_business_event(
|
|
|
|
|
event_type="subscription.changed",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
data={
|
|
|
|
|
"tenant_id": str(tenant_id),
|
|
|
|
|
"old_tier": current_subscription.plan,
|
|
|
|
|
"new_tier": new_plan,
|
|
|
|
|
"action": "upgrade"
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
logger.info("Published subscription change event",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
event_type="subscription.changed")
|
|
|
|
|
except Exception as event_error:
|
|
|
|
|
logger.error("Failed to publish subscription change event",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(event_error))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": f"Plan successfully upgraded to {new_plan}",
|
|
|
|
|
"old_plan": current_subscription.plan,
|
|
|
|
|
"new_plan": new_plan,
|
|
|
|
|
"new_monthly_price": updated_subscription.monthly_price,
|
|
|
|
|
"validation": validation,
|
|
|
|
|
"requires_token_refresh": True # Signal to frontend
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to upgrade subscription plan",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
new_plan=new_plan,
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to upgrade subscription plan"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/api/v1/subscriptions/register-with-subscription")
|
|
|
|
|
async def register_with_subscription(
|
|
|
|
|
user_data: dict = Depends(get_current_user_dep),
|
|
|
|
|
plan_id: str = Query(..., description="Plan ID to subscribe to"),
|
|
|
|
|
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
|
|
|
|
|
use_trial: bool = Query(False, description="Whether to use trial period for pilot users"),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Process user registration with subscription creation"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Use SubscriptionService for registration
|
|
|
|
|
subscription_service = SubscriptionService(db)
|
|
|
|
|
|
|
|
|
|
result = await subscription_service.create_subscription(
|
|
|
|
|
user_data.get('tenant_id'),
|
|
|
|
|
plan_id,
|
|
|
|
|
payment_method_id,
|
|
|
|
|
14 if use_trial else None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Registration and subscription created successfully",
|
|
|
|
|
"data": result
|
|
|
|
|
}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to register with subscription", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to register with subscription"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/api/v1/subscriptions/{tenant_id}/update-payment-method")
|
|
|
|
|
async def update_payment_method(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
payment_method_id: str = Query(..., description="New payment method ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Update the default payment method for a subscription
|
|
|
|
|
|
|
|
|
|
This endpoint allows users to change their payment method through the UI.
|
|
|
|
|
It updates the default payment method in Stripe and returns the updated
|
|
|
|
|
payment method information.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Use SubscriptionService to get subscription and update payment method
|
|
|
|
|
subscription_service = SubscriptionService(db)
|
|
|
|
|
|
|
|
|
|
# Get current subscription
|
|
|
|
|
subscription = await subscription_service.get_subscription_by_tenant_id(tenant_id)
|
|
|
|
|
if not subscription:
|
|
|
|
|
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
|
|
|
|
|
|
|
|
|
|
if not subscription.stripe_customer_id:
|
|
|
|
|
raise ValidationError(f"Tenant {tenant_id} does not have a Stripe customer ID")
|
|
|
|
|
|
|
|
|
|
# Update payment method via PaymentService
|
|
|
|
|
payment_result = await subscription_service.payment_service.update_payment_method(
|
|
|
|
|
subscription.stripe_customer_id,
|
|
|
|
|
payment_method_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("Payment method updated successfully",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
payment_method_id=payment_method_id,
|
|
|
|
|
user_id=current_user.get("user_id"))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Payment method updated successfully",
|
|
|
|
|
"payment_method_id": payment_result.id,
|
|
|
|
|
"brand": getattr(payment_result, 'brand', 'unknown'),
|
|
|
|
|
"last4": getattr(payment_result, 'last4', '0000'),
|
|
|
|
|
"exp_month": getattr(payment_result, 'exp_month', None),
|
|
|
|
|
"exp_year": getattr(payment_result, 'exp_year', None)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except ValidationError as ve:
|
|
|
|
|
logger.error("update_payment_method_validation_failed",
|
|
|
|
|
error=str(ve), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(ve)
|
|
|
|
|
)
|
|
|
|
|
except DatabaseError as de:
|
|
|
|
|
logger.error("update_payment_method_failed",
|
|
|
|
|
error=str(de), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to update payment method"
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("update_payment_method_unexpected_error",
|
|
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="An unexpected error occurred while updating payment method"
|
|
|
|
|
)
|