2025-10-16 07:28:04 +02:00
|
|
|
"""
|
|
|
|
|
Subscription management API for GDPR-compliant cancellation and reactivation
|
|
|
|
|
"""
|
|
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, Body
|
2025-10-16 07:28:04 +02:00
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
|
from uuid import UUID
|
2026-01-13 22:22:38 +01:00
|
|
|
from typing import Optional, Dict, Any, List
|
2025-10-16 07:28:04 +02:00
|
|
|
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
|
2026-01-13 22:22:38 +01:00
|
|
|
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
|
|
|
|
|
from app.services.payment_service import PaymentService
|
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-13 22:22:38 +01:00
|
|
|
# Use orchestration service for complete workflow
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
result = await orchestration_service.orchestrate_subscription_cancellation(
|
2026-01-11 21:40:04 +01:00
|
|
|
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-13 22:22:38 +01:00
|
|
|
# Use orchestration service for complete workflow
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
result = await orchestration_service.orchestrate_subscription_reactivation(
|
2026-01-11 21:40:04 +01:00
|
|
|
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-13 22:22:38 +01:00
|
|
|
# Use service layer for invoice retrieval
|
2026-01-11 21:40:04 +01:00
|
|
|
subscription_service = SubscriptionService(db)
|
2026-01-13 22:22:38 +01:00
|
|
|
payment_service = PaymentService()
|
|
|
|
|
invoices_data = await subscription_service.get_tenant_invoices(tenant_id, payment_service)
|
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"),
|
2026-01-13 22:22:38 +01:00
|
|
|
billing_cycle: Optional[str] = Query(None, description="Billing cycle (monthly/yearly)"),
|
|
|
|
|
immediate_change: bool = Query(True, description="Apply change immediately"),
|
2026-01-11 21:40:04 +01:00
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
2026-01-13 22:22:38 +01:00
|
|
|
"""
|
|
|
|
|
Upgrade subscription plan for a tenant.
|
|
|
|
|
|
|
|
|
|
This endpoint:
|
|
|
|
|
1. Validates the upgrade is allowed
|
|
|
|
|
2. Calculates proration costs
|
|
|
|
|
3. Updates subscription in Stripe
|
|
|
|
|
4. Updates local database
|
|
|
|
|
5. Invalidates caches and tokens
|
|
|
|
|
"""
|
2026-01-11 21:40:04 +01:00
|
|
|
|
|
|
|
|
try:
|
2026-01-13 22:22:38 +01:00
|
|
|
# Step 1: Validate the upgrade
|
2026-01-11 21:40:04 +01:00
|
|
|
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")
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
# Step 2: Get current subscription to determine billing cycle
|
2026-01-11 21:40:04 +01:00
|
|
|
subscription_service = SubscriptionService(db)
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
# Use current billing cycle if not provided
|
|
|
|
|
if not billing_cycle:
|
|
|
|
|
billing_cycle = current_subscription.billing_interval or "monthly"
|
|
|
|
|
|
|
|
|
|
# Step 3: Use orchestration service for the upgrade
|
|
|
|
|
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
upgrade_result = await orchestration_service.orchestrate_plan_upgrade(
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
new_plan=new_plan,
|
|
|
|
|
proration_behavior="create_prorations",
|
|
|
|
|
immediate_change=immediate_change,
|
|
|
|
|
billing_cycle=billing_cycle
|
2026-01-11 21:40:04 +01:00
|
|
|
)
|
|
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
# Step 4: Invalidate subscription cache
|
2026-01-11 21:40:04 +01:00
|
|
|
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))
|
|
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
# Step 5: Invalidate all existing tokens for this tenant
|
2026-01-11 21:40:04 +01:00
|
|
|
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),
|
2026-01-13 22:22:38 +01:00
|
|
|
ex=86400
|
2026-01-11 21:40:04 +01:00
|
|
|
)
|
|
|
|
|
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))
|
|
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
# Step 6: Publish event for real-time notification
|
2026-01-11 21:40:04 +01:00
|
|
|
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,
|
2026-01-13 22:22:38 +01:00
|
|
|
"proration_details": upgrade_result.get("proration_details"),
|
2026-01-11 21:40:04 +01:00
|
|
|
"validation": validation,
|
2026-01-13 22:22:38 +01:00
|
|
|
"requires_token_refresh": True
|
2026-01-11 21:40:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-01-13 22:22:38 +01:00
|
|
|
detail=f"Failed to upgrade subscription plan: {str(e)}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/api/v1/subscriptions/{tenant_id}/change-billing-cycle")
|
|
|
|
|
async def change_billing_cycle(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
new_billing_cycle: str = Query(..., description="New billing cycle (monthly/yearly)"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Change billing cycle for a tenant's subscription.
|
|
|
|
|
|
|
|
|
|
This endpoint:
|
|
|
|
|
1. Validates the tenant has an active subscription
|
|
|
|
|
2. Calculates proration costs
|
|
|
|
|
3. Updates subscription in Stripe
|
|
|
|
|
4. Updates local database
|
|
|
|
|
5. Returns proration details to user
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Validate billing cycle parameter
|
|
|
|
|
if new_billing_cycle not in ["monthly", "yearly"]:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Billing cycle must be 'monthly' or 'yearly'"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Get current subscription
|
|
|
|
|
subscription_service = SubscriptionService(db)
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Check if already on requested billing cycle
|
|
|
|
|
current_cycle = current_subscription.billing_interval or "monthly"
|
|
|
|
|
if current_cycle == new_billing_cycle:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=f"Subscription is already on {new_billing_cycle} billing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Use orchestration service for the billing cycle change
|
|
|
|
|
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
change_result = await orchestration_service.orchestrate_billing_cycle_change(
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
new_billing_cycle=new_billing_cycle,
|
|
|
|
|
immediate_change=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 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 billing cycle change",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
new_billing_cycle=new_billing_cycle)
|
|
|
|
|
except Exception as cache_error:
|
|
|
|
|
logger.error("Failed to invalidate subscription cache",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(cache_error))
|
|
|
|
|
|
|
|
|
|
# Publish event for real-time notification
|
|
|
|
|
try:
|
|
|
|
|
from shared.messaging import UnifiedEventPublisher
|
|
|
|
|
event_publisher = UnifiedEventPublisher()
|
|
|
|
|
await event_publisher.publish_business_event(
|
|
|
|
|
event_type="subscription.billing_cycle_changed",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
data={
|
|
|
|
|
"tenant_id": str(tenant_id),
|
|
|
|
|
"old_billing_cycle": current_cycle,
|
|
|
|
|
"new_billing_cycle": new_billing_cycle,
|
|
|
|
|
"action": "billing_cycle_change"
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
logger.info("Published billing cycle change event",
|
|
|
|
|
tenant_id=str(tenant_id))
|
|
|
|
|
except Exception as event_error:
|
|
|
|
|
logger.error("Failed to publish billing cycle change event",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
error=str(event_error))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": f"Billing cycle changed to {new_billing_cycle}",
|
|
|
|
|
"old_billing_cycle": current_cycle,
|
|
|
|
|
"new_billing_cycle": new_billing_cycle,
|
|
|
|
|
"proration_details": change_result.get("proration_details")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to change billing cycle",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
new_billing_cycle=new_billing_cycle,
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail=f"Failed to change billing cycle: {str(e)}"
|
2026-01-11 21:40:04 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/api/v1/subscriptions/register-with-subscription")
|
|
|
|
|
async def register_with_subscription(
|
|
|
|
|
user_data: dict = Depends(get_current_user_dep),
|
2026-01-13 22:22:38 +01:00
|
|
|
plan_id: str = Query(..., description="Plan ID to subscribe to (starter, professional, enterprise)"),
|
2026-01-11 21:40:04 +01:00
|
|
|
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
|
2026-01-13 22:22:38 +01:00
|
|
|
coupon_code: Optional[str] = Query(None, description="Coupon code to apply (e.g., PILOT2025)"),
|
|
|
|
|
billing_interval: str = Query("monthly", description="Billing interval (monthly or yearly)"),
|
2026-01-11 21:40:04 +01:00
|
|
|
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,
|
2026-01-13 22:22:38 +01:00
|
|
|
None, # Trial period handled by coupon logic
|
|
|
|
|
billing_interval,
|
|
|
|
|
coupon_code # Pass coupon code for trial period determination
|
2026-01-11 21:40:04 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Registration and subscription created successfully",
|
2026-01-14 13:15:48 +01:00
|
|
|
**result
|
2026-01-11 21:40:04 +01:00
|
|
|
}
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
@router.post("/api/v1/subscriptions/{tenant_id}/create")
|
|
|
|
|
async def create_subscription_endpoint(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
plan_id: str = Query(..., description="Plan ID (starter, professional, enterprise)"),
|
|
|
|
|
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
|
|
|
|
|
billing_interval: str = Query("monthly", description="Billing interval (monthly or yearly)"),
|
|
|
|
|
trial_period_days: Optional[int] = Query(None, description="Trial period in days"),
|
|
|
|
|
coupon_code: Optional[str] = Query(None, description="Optional coupon code"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Create a new subscription for a tenant using orchestration service
|
|
|
|
|
|
|
|
|
|
This endpoint orchestrates the complete subscription creation workflow
|
|
|
|
|
including payment provider integration and tenant updates.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Prepare user data for orchestration service
|
|
|
|
|
user_data = {
|
|
|
|
|
'user_id': current_user.get('sub'),
|
|
|
|
|
'email': current_user.get('email'),
|
|
|
|
|
'full_name': current_user.get('name', 'Unknown User'),
|
|
|
|
|
'tenant_id': tenant_id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Use orchestration service for complete workflow
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
result = await orchestration_service.orchestrate_subscription_creation(
|
|
|
|
|
tenant_id,
|
|
|
|
|
user_data,
|
|
|
|
|
plan_id,
|
|
|
|
|
payment_method_id,
|
|
|
|
|
billing_interval,
|
|
|
|
|
coupon_code
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("subscription_created_via_orchestration",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
plan_id=plan_id,
|
|
|
|
|
billing_interval=billing_interval,
|
|
|
|
|
coupon_applied=result.get("coupon_applied", False))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Subscription created successfully",
|
2026-01-14 13:15:48 +01:00
|
|
|
**result
|
2026-01-13 22:22:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to create subscription via API",
|
|
|
|
|
error=str(e),
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
plan_id=plan_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to create subscription"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class CreateForRegistrationRequest(BaseModel):
|
|
|
|
|
"""Request model for create-for-registration endpoint"""
|
|
|
|
|
user_data: dict = Field(..., description="User data for subscription creation")
|
|
|
|
|
plan_id: str = Field(..., description="Plan ID (starter, professional, enterprise)")
|
|
|
|
|
payment_method_id: str = Field(..., description="Payment method ID from frontend")
|
|
|
|
|
billing_interval: str = Field("monthly", description="Billing interval (monthly or yearly)")
|
|
|
|
|
coupon_code: Optional[str] = Field(None, description="Optional coupon code")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/api/v1/subscriptions/create-for-registration")
|
|
|
|
|
async def create_subscription_for_registration(
|
|
|
|
|
request: CreateForRegistrationRequest = Body(..., description="Subscription creation request"),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Create a tenant-independent subscription during user registration
|
|
|
|
|
|
|
|
|
|
This endpoint creates a subscription that is not linked to any tenant.
|
|
|
|
|
The subscription will be linked to a tenant during the onboarding flow.
|
|
|
|
|
|
|
|
|
|
This is used during the new registration flow where users register
|
|
|
|
|
and pay before creating their tenant/bakery.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
logger.info("Creating tenant-independent subscription for registration",
|
|
|
|
|
user_id=request.user_data.get('user_id'),
|
|
|
|
|
plan_id=request.plan_id)
|
|
|
|
|
|
|
|
|
|
# Use orchestration service for tenant-independent subscription creation
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
result = await orchestration_service.create_tenant_independent_subscription(
|
|
|
|
|
request.user_data,
|
|
|
|
|
request.plan_id,
|
|
|
|
|
request.payment_method_id,
|
|
|
|
|
request.billing_interval,
|
|
|
|
|
request.coupon_code
|
|
|
|
|
)
|
2026-01-14 13:15:48 +01:00
|
|
|
|
|
|
|
|
# Check if result requires SetupIntent confirmation (3DS)
|
|
|
|
|
if result.get('requires_action'):
|
|
|
|
|
logger.info("Subscription creation requires SetupIntent confirmation",
|
|
|
|
|
user_id=request.user_data.get('user_id'),
|
|
|
|
|
action_type=result.get('action_type'),
|
|
|
|
|
setup_intent_id=result.get('setup_intent_id'))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Payment method verification required",
|
|
|
|
|
**result # Spread all result fields to top level for frontend compatibility
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Normal subscription creation (no 3DS)
|
2026-01-13 22:22:38 +01:00
|
|
|
logger.info("Tenant-independent subscription created successfully",
|
|
|
|
|
user_id=request.user_data.get('user_id'),
|
2026-01-14 13:15:48 +01:00
|
|
|
subscription_id=result.get("subscription_id"),
|
2026-01-13 22:22:38 +01:00
|
|
|
plan_id=request.plan_id)
|
2026-01-14 13:15:48 +01:00
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Tenant-independent subscription created successfully",
|
2026-01-14 13:15:48 +01:00
|
|
|
**result
|
2026-01-13 22:22:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to create tenant-independent subscription",
|
|
|
|
|
error=str(e),
|
|
|
|
|
user_id=request.user_data.get('user_id'),
|
|
|
|
|
plan_id=request.plan_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to create tenant-independent subscription"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-01-14 13:15:48 +01:00
|
|
|
@router.post("/api/v1/subscriptions/complete-after-setup-intent")
|
|
|
|
|
async def complete_subscription_after_setup_intent(
|
|
|
|
|
request: dict = Body(..., description="Completion request with setup_intent_id"),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Complete subscription creation after SetupIntent confirmation
|
|
|
|
|
|
|
|
|
|
This endpoint is called by the frontend after successfully confirming a SetupIntent
|
|
|
|
|
(with or without 3DS). It verifies the SetupIntent and creates the subscription.
|
|
|
|
|
|
|
|
|
|
Request body should contain:
|
|
|
|
|
- setup_intent_id: The SetupIntent ID that was confirmed
|
|
|
|
|
- customer_id: Stripe customer ID (from initial response)
|
|
|
|
|
- plan_id: Subscription plan ID
|
|
|
|
|
- payment_method_id: Payment method ID
|
|
|
|
|
- trial_period_days: Optional trial period
|
|
|
|
|
- user_id: User ID (for linking subscription to user)
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
setup_intent_id = request.get('setup_intent_id')
|
|
|
|
|
customer_id = request.get('customer_id')
|
|
|
|
|
plan_id = request.get('plan_id')
|
|
|
|
|
payment_method_id = request.get('payment_method_id')
|
|
|
|
|
trial_period_days = request.get('trial_period_days')
|
|
|
|
|
user_id = request.get('user_id')
|
|
|
|
|
billing_interval = request.get('billing_interval', 'monthly')
|
|
|
|
|
|
|
|
|
|
if not all([setup_intent_id, customer_id, plan_id, payment_method_id, user_id]):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Missing required fields: setup_intent_id, customer_id, plan_id, payment_method_id, user_id"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("Completing subscription after SetupIntent confirmation",
|
|
|
|
|
setup_intent_id=setup_intent_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
plan_id=plan_id)
|
|
|
|
|
|
|
|
|
|
# Use orchestration service to complete subscription
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
result = await orchestration_service.complete_subscription_after_setup_intent(
|
|
|
|
|
setup_intent_id=setup_intent_id,
|
|
|
|
|
customer_id=customer_id,
|
|
|
|
|
plan_id=plan_id,
|
|
|
|
|
payment_method_id=payment_method_id,
|
|
|
|
|
trial_period_days=trial_period_days,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
billing_interval=billing_interval
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("Subscription completed successfully after SetupIntent",
|
|
|
|
|
setup_intent_id=setup_intent_id,
|
|
|
|
|
subscription_id=result.get('subscription_id'),
|
|
|
|
|
user_id=user_id)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"message": "Subscription created successfully after SetupIntent confirmation",
|
|
|
|
|
**result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to complete subscription after SetupIntent",
|
|
|
|
|
error=str(e),
|
|
|
|
|
setup_intent_id=request.get('setup_intent_id'))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail=f"Failed to complete subscription: {str(e)}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/v1/subscriptions/{tenant_id}/payment-method")
|
|
|
|
|
async def get_payment_method(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Get the current payment method for a subscription
|
|
|
|
|
|
|
|
|
|
This endpoint retrieves the current payment method details from the payment provider
|
|
|
|
|
for display in the UI, including brand, last4 digits, and expiration date.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Use SubscriptionOrchestrationService to get payment method
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
payment_method = await orchestration_service.get_payment_method(tenant_id)
|
|
|
|
|
|
|
|
|
|
if not payment_method:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="No payment method found for this subscription"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("payment_method_retrieved_via_api",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=current_user.get("user_id"))
|
|
|
|
|
|
|
|
|
|
return payment_method
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except ValidationError as ve:
|
|
|
|
|
logger.error("get_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("get_payment_method_failed",
|
|
|
|
|
error=str(de), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to retrieve payment method"
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("get_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 retrieving payment method"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-01-11 21:40:04 +01:00
|
|
|
@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
|
2026-01-14 13:15:48 +01:00
|
|
|
|
2026-01-11 21:40:04 +01:00
|
|
|
This endpoint allows users to change their payment method through the UI.
|
2026-01-14 13:15:48 +01:00
|
|
|
It updates the default payment method with the payment provider and returns
|
|
|
|
|
the updated payment method information.
|
2026-01-11 21:40:04 +01:00
|
|
|
"""
|
|
|
|
|
try:
|
2026-01-14 13:15:48 +01:00
|
|
|
# Use SubscriptionOrchestrationService to update payment method
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
result = await orchestration_service.update_payment_method(
|
|
|
|
|
tenant_id,
|
2026-01-11 21:40:04 +01:00
|
|
|
payment_method_id
|
|
|
|
|
)
|
2026-01-14 13:15:48 +01:00
|
|
|
|
2026-01-11 21:40:04 +01:00
|
|
|
logger.info("Payment method updated successfully",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
payment_method_id=payment_method_id,
|
|
|
|
|
user_id=current_user.get("user_id"))
|
2026-01-14 13:15:48 +01:00
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
2026-01-11 21:40:04 +01:00
|
|
|
except ValidationError as ve:
|
2026-01-14 13:15:48 +01:00
|
|
|
logger.error("update_payment_method_validation_failed",
|
2026-01-11 21:40:04 +01:00
|
|
|
error=str(ve), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(ve)
|
|
|
|
|
)
|
|
|
|
|
except DatabaseError as de:
|
2026-01-14 13:15:48 +01:00
|
|
|
logger.error("update_payment_method_failed",
|
2026-01-11 21:40:04 +01:00
|
|
|
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:
|
2026-01-14 13:15:48 +01:00
|
|
|
logger.error("update_payment_method_unexpected_error",
|
2026-01-11 21:40:04 +01:00
|
|
|
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"
|
|
|
|
|
)
|
2026-01-13 22:22:38 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# NEW SUBSCRIPTION UPDATE ENDPOINTS WITH PRORATION SUPPORT
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
class SubscriptionChangePreviewRequest(BaseModel):
|
|
|
|
|
"""Request model for subscription change preview"""
|
|
|
|
|
new_plan: str = Field(..., description="New plan name (starter, professional, enterprise) or 'same' for billing cycle changes")
|
|
|
|
|
proration_behavior: str = Field("create_prorations", description="Proration behavior (create_prorations, none, always_invoice)")
|
|
|
|
|
billing_cycle: str = Field("monthly", description="Billing cycle for the new plan (monthly, yearly)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SubscriptionChangePreviewResponse(BaseModel):
|
|
|
|
|
"""Response model for subscription change preview"""
|
|
|
|
|
success: bool
|
|
|
|
|
current_plan: str
|
|
|
|
|
current_billing_cycle: str
|
|
|
|
|
current_price: float
|
|
|
|
|
new_plan: str
|
|
|
|
|
new_billing_cycle: str
|
|
|
|
|
new_price: float
|
|
|
|
|
proration_details: Dict[str, Any]
|
|
|
|
|
current_plan_features: List[str]
|
|
|
|
|
new_plan_features: List[str]
|
|
|
|
|
change_type: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/api/v1/subscriptions/{tenant_id}/preview-change", response_model=SubscriptionChangePreviewResponse)
|
|
|
|
|
async def preview_subscription_change(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
request: SubscriptionChangePreviewRequest = Body(...),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Preview the cost impact of a subscription change
|
|
|
|
|
|
|
|
|
|
This endpoint allows users to see the proration details before confirming a subscription change.
|
|
|
|
|
It shows the cost difference, credits, and other financial impacts of changing plans or billing cycles.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Use SubscriptionService for preview
|
|
|
|
|
subscription_service = SubscriptionService(db)
|
|
|
|
|
|
|
|
|
|
# Create payment service for proration calculation
|
|
|
|
|
payment_service = PaymentService()
|
|
|
|
|
result = await subscription_service.preview_subscription_change(
|
|
|
|
|
tenant_id,
|
|
|
|
|
request.new_plan,
|
|
|
|
|
request.proration_behavior,
|
|
|
|
|
request.billing_cycle,
|
|
|
|
|
payment_service
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("subscription_change_previewed",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=current_user.get("user_id"),
|
|
|
|
|
new_plan=request.new_plan,
|
|
|
|
|
proration_amount=result["proration_details"].get("net_amount", 0))
|
|
|
|
|
|
|
|
|
|
return SubscriptionChangePreviewResponse(**result)
|
|
|
|
|
|
|
|
|
|
except ValidationError as ve:
|
|
|
|
|
logger.error("preview_subscription_change_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("preview_subscription_change_failed",
|
|
|
|
|
error=str(de), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to preview subscription change"
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("preview_subscription_change_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 previewing subscription change"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SubscriptionPlanUpdateRequest(BaseModel):
|
|
|
|
|
"""Request model for subscription plan update"""
|
|
|
|
|
new_plan: str = Field(..., description="New plan name (starter, professional, enterprise)")
|
|
|
|
|
proration_behavior: str = Field("create_prorations", description="Proration behavior (create_prorations, none, always_invoice)")
|
|
|
|
|
immediate_change: bool = Field(False, description="Whether to apply changes immediately or at period end")
|
|
|
|
|
billing_cycle: str = Field("monthly", description="Billing cycle for the new plan (monthly, yearly)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SubscriptionPlanUpdateResponse(BaseModel):
|
|
|
|
|
"""Response model for subscription plan update"""
|
|
|
|
|
success: bool
|
|
|
|
|
message: str
|
|
|
|
|
old_plan: str
|
|
|
|
|
new_plan: str
|
|
|
|
|
proration_details: Dict[str, Any]
|
|
|
|
|
immediate_change: bool
|
|
|
|
|
new_status: str
|
|
|
|
|
new_period_end: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/api/v1/subscriptions/{tenant_id}/update-plan", response_model=SubscriptionPlanUpdateResponse)
|
|
|
|
|
async def update_subscription_plan(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
request: SubscriptionPlanUpdateRequest = Body(...),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Update subscription plan with proration support
|
|
|
|
|
|
|
|
|
|
This endpoint allows users to change their subscription plan with proper proration handling.
|
|
|
|
|
It supports both immediate changes and changes that take effect at the end of the billing period.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Use orchestration service for complete plan upgrade workflow
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
result = await orchestration_service.orchestrate_plan_upgrade(
|
|
|
|
|
tenant_id,
|
|
|
|
|
request.new_plan,
|
|
|
|
|
request.proration_behavior,
|
|
|
|
|
request.immediate_change,
|
|
|
|
|
request.billing_cycle
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("subscription_plan_updated",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=current_user.get("user_id"),
|
|
|
|
|
old_plan=result["old_plan"],
|
|
|
|
|
new_plan=result["new_plan"],
|
|
|
|
|
proration_amount=result["proration_details"].get("net_amount", 0),
|
|
|
|
|
immediate_change=request.immediate_change)
|
|
|
|
|
|
|
|
|
|
return SubscriptionPlanUpdateResponse(**result)
|
|
|
|
|
|
|
|
|
|
except ValidationError as ve:
|
|
|
|
|
logger.error("update_subscription_plan_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_subscription_plan_failed",
|
|
|
|
|
error=str(de), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to update subscription plan"
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("update_subscription_plan_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 subscription plan"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BillingCycleChangeRequest(BaseModel):
|
|
|
|
|
"""Request model for billing cycle change"""
|
|
|
|
|
new_billing_cycle: str = Field(..., description="New billing cycle (monthly, yearly)")
|
|
|
|
|
proration_behavior: str = Field("create_prorations", description="Proration behavior (create_prorations, none, always_invoice)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BillingCycleChangeResponse(BaseModel):
|
|
|
|
|
"""Response model for billing cycle change"""
|
|
|
|
|
success: bool
|
|
|
|
|
message: str
|
|
|
|
|
old_billing_cycle: str
|
|
|
|
|
new_billing_cycle: str
|
|
|
|
|
proration_details: Dict[str, Any]
|
|
|
|
|
new_status: str
|
|
|
|
|
new_period_end: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/api/v1/subscriptions/{tenant_id}/change-billing-cycle", response_model=BillingCycleChangeResponse)
|
|
|
|
|
async def change_billing_cycle(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
request: BillingCycleChangeRequest = Body(...),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Change billing cycle (monthly ↔ yearly) for a subscription
|
|
|
|
|
|
|
|
|
|
This endpoint allows users to switch between monthly and yearly billing cycles.
|
|
|
|
|
It handles proration and creates appropriate charges or credits.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Use orchestration service for complete billing cycle change workflow
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
result = await orchestration_service.orchestrate_billing_cycle_change(
|
|
|
|
|
tenant_id,
|
|
|
|
|
request.new_billing_cycle,
|
|
|
|
|
request.proration_behavior
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("subscription_billing_cycle_changed",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=current_user.get("user_id"),
|
|
|
|
|
old_billing_cycle=result["old_billing_cycle"],
|
|
|
|
|
new_billing_cycle=result["new_billing_cycle"],
|
|
|
|
|
proration_amount=result["proration_details"].get("net_amount", 0))
|
|
|
|
|
|
|
|
|
|
return BillingCycleChangeResponse(**result)
|
|
|
|
|
|
|
|
|
|
except ValidationError as ve:
|
|
|
|
|
logger.error("change_billing_cycle_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("change_billing_cycle_failed",
|
|
|
|
|
error=str(de), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to change billing cycle"
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("change_billing_cycle_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 changing billing cycle"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# COUPON REDEMPTION ENDPOINTS
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
class CouponRedemptionRequest(BaseModel):
|
|
|
|
|
"""Request model for coupon redemption"""
|
|
|
|
|
coupon_code: str = Field(..., description="Coupon code to redeem")
|
|
|
|
|
base_trial_days: int = Field(14, description="Base trial days without coupon")
|
|
|
|
|
|
|
|
|
|
class CouponRedemptionResponse(BaseModel):
|
|
|
|
|
"""Response model for coupon redemption"""
|
|
|
|
|
success: bool
|
|
|
|
|
coupon_applied: bool
|
|
|
|
|
discount: Optional[Dict[str, Any]] = None
|
|
|
|
|
message: str
|
|
|
|
|
error: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
@router.post("/api/v1/subscriptions/{tenant_id}/redeem-coupon", response_model=CouponRedemptionResponse)
|
|
|
|
|
async def redeem_coupon(
|
|
|
|
|
tenant_id: str = Path(..., description="Tenant ID"),
|
|
|
|
|
request: CouponRedemptionRequest = Body(...),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Redeem a coupon for a tenant
|
|
|
|
|
|
|
|
|
|
This endpoint handles the complete coupon redemption workflow including
|
|
|
|
|
validation, redemption, and tenant updates.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Use orchestration service for complete coupon redemption workflow
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
result = await orchestration_service.orchestrate_coupon_redemption(
|
|
|
|
|
tenant_id,
|
|
|
|
|
request.coupon_code,
|
|
|
|
|
request.base_trial_days
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("coupon_redeemed",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=current_user.get("user_id"),
|
|
|
|
|
coupon_code=request.coupon_code,
|
|
|
|
|
success=result["success"])
|
|
|
|
|
|
|
|
|
|
return CouponRedemptionResponse(
|
|
|
|
|
success=result["success"],
|
|
|
|
|
coupon_applied=result.get("coupon_applied", False),
|
|
|
|
|
discount=result.get("discount"),
|
|
|
|
|
message=result.get("message", "Coupon redemption processed"),
|
|
|
|
|
error=result.get("error")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except ValidationError as ve:
|
|
|
|
|
logger.error("coupon_redemption_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("coupon_redemption_failed", error=str(de), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to redeem coupon"
|
|
|
|
|
)
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("coupon_redemption_failed", error=str(e), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="An unexpected error occurred while redeeming coupon"
|
|
|
|
|
)
|
2026-01-14 13:15:48 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# NEW ENDPOINTS FOR SECURE REGISTRATION ARCHITECTURE
|
|
|
|
|
|
|
|
|
|
class PaymentCustomerCreationRequest(BaseModel):
|
|
|
|
|
"""Request model for payment customer creation (pre-user-creation)"""
|
|
|
|
|
user_data: Dict[str, Any]
|
|
|
|
|
payment_method_id: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/payment-customers/create")
|
|
|
|
|
async def create_payment_customer_for_registration(
|
|
|
|
|
request: PaymentCustomerCreationRequest,
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Create payment customer (supports pre-user-creation flow)
|
|
|
|
|
|
|
|
|
|
This endpoint creates a payment customer without requiring a user_id,
|
|
|
|
|
supporting the secure architecture where users are only created after
|
|
|
|
|
payment verification.
|
|
|
|
|
|
|
|
|
|
Uses SubscriptionOrchestrationService for proper workflow coordination.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
request: Payment customer creation request
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary with payment customer creation result
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
logger.info("Creating payment customer for registration (pre-user creation)",
|
|
|
|
|
email=request.user_data.get('email'),
|
|
|
|
|
payment_method_id=request.payment_method_id)
|
|
|
|
|
|
|
|
|
|
# Use orchestration service for proper workflow coordination
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
result = await orchestration_service.create_registration_payment_setup(
|
|
|
|
|
user_data=request.user_data,
|
|
|
|
|
plan_id=request.plan_id if hasattr(request, 'plan_id') else "professional",
|
|
|
|
|
payment_method_id=request.payment_method_id,
|
|
|
|
|
billing_interval="monthly", # Default for registration
|
|
|
|
|
coupon_code=request.user_data.get('coupon_code')
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("Payment setup completed for registration",
|
|
|
|
|
email=request.user_data.get('email'),
|
|
|
|
|
requires_action=result.get('requires_action'),
|
|
|
|
|
setup_intent_id=result.get('setup_intent_id'))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
**result # Include all orchestration service results
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to create payment customer for registration",
|
|
|
|
|
email=request.user_data.get('email'),
|
|
|
|
|
error=str(e),
|
|
|
|
|
exc_info=True)
|
|
|
|
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to create payment customer: " + str(e)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/setup-intents/{setup_intent_id}/verify")
|
|
|
|
|
async def verify_setup_intent(
|
|
|
|
|
setup_intent_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Verify SetupIntent status with payment provider
|
|
|
|
|
|
|
|
|
|
This endpoint checks if a SetupIntent has been successfully confirmed
|
|
|
|
|
(either automatically or via 3DS authentication).
|
|
|
|
|
|
|
|
|
|
Uses SubscriptionOrchestrationService for proper workflow coordination.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
setup_intent_id: SetupIntent ID to verify
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary with SetupIntent verification result
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
logger.info("Verifying SetupIntent status",
|
|
|
|
|
setup_intent_id=setup_intent_id)
|
|
|
|
|
|
|
|
|
|
# Use orchestration service for proper workflow coordination
|
|
|
|
|
orchestration_service = SubscriptionOrchestrationService(db)
|
|
|
|
|
|
|
|
|
|
# Verify SetupIntent using orchestration service
|
|
|
|
|
result = await orchestration_service.verify_setup_intent_for_registration(
|
|
|
|
|
setup_intent_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("SetupIntent verification result",
|
|
|
|
|
setup_intent_id=setup_intent_id,
|
|
|
|
|
status=result.get('status'))
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to verify SetupIntent",
|
|
|
|
|
setup_intent_id=setup_intent_id,
|
|
|
|
|
error=str(e),
|
|
|
|
|
exc_info=True)
|
|
|
|
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to verify SetupIntent: " + str(e)
|
|
|
|
|
)
|