Files
bakery-ia/services/tenant/app/api/subscription.py
2026-01-14 13:15:48 +01:00

1622 lines
61 KiB
Python

"""
Subscription management API for GDPR-compliant cancellation and reactivation
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, Body
from pydantic import BaseModel, Field
from datetime import datetime, timezone, timedelta
from uuid import UUID
from typing import Optional, Dict, Any, List
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import json
from shared.auth.decorators import get_current_user_dep, require_admin_role_dep
from shared.routing import RouteBuilder
from app.core.database import get_db
from app.models.tenants import Subscription, Tenant
from app.services.subscription_limit_service import SubscriptionLimitService
from app.services.subscription_service import SubscriptionService
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
from app.services.payment_service import PaymentService
from shared.clients.stripe_client import StripeProvider
from app.core.config import settings
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
logger = structlog.get_logger()
router = APIRouter()
route_builder = RouteBuilder('tenant')
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"
)
class QuotaCheckResponse(BaseModel):
"""Response for quota limit checks"""
allowed: bool
message: str
limit: int
current_count: int
max_allowed: int
reason: str
requested_amount: int
available_amount: int
class SubscriptionCancellationRequest(BaseModel):
"""Request model for subscription cancellation"""
tenant_id: str = Field(..., description="Tenant ID to cancel subscription for")
reason: str = Field(default="", description="Optional cancellation reason")
class SubscriptionCancellationResponse(BaseModel):
"""Response for subscription cancellation"""
success: bool
message: str
status: str
cancellation_effective_date: str
days_remaining: int
read_only_mode_starts: str
class SubscriptionReactivationRequest(BaseModel):
"""Request model for subscription reactivation"""
tenant_id: str = Field(..., description="Tenant ID to reactivate subscription for")
plan: str = Field(default="starter", description="Plan to reactivate with")
class SubscriptionStatusResponse(BaseModel):
"""Response for subscription status check"""
tenant_id: str
status: str
plan: str
is_read_only: bool
cancellation_effective_date: str | None
days_until_inactive: int | None
class InvoiceResponse(BaseModel):
"""Response model for an invoice"""
id: str
date: str
amount: float
currency: str
status: str
description: str | None = None
invoice_pdf: str | None = None
hosted_invoice_url: str | None = None
@router.post("/api/v1/subscriptions/cancel", response_model=SubscriptionCancellationResponse)
async def cancel_subscription(
request: SubscriptionCancellationRequest,
current_user: dict = Depends(require_admin_role_dep),
db: AsyncSession = Depends(get_db)
):
"""
Cancel subscription - Downgrade to read-only mode
Flow:
1. Set status to 'pending_cancellation'
2. Calculate cancellation_effective_date (end of billing period)
3. User keeps access until effective date
4. Background job converts to 'inactive' at effective date
5. Gateway enforces read-only mode for 'pending_cancellation' and 'inactive' statuses
"""
try:
# Use orchestration service for complete workflow
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.orchestrate_subscription_cancellation(
request.tenant_id,
request.reason
)
logger.info(
"subscription_cancelled",
tenant_id=request.tenant_id,
user_id=current_user.get("sub"),
effective_date=result["cancellation_effective_date"],
reason=request.reason[:200] if request.reason else None
)
return SubscriptionCancellationResponse(
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"]
)
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"
)
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:
# Use orchestration service for complete workflow
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.orchestrate_subscription_reactivation(
request.tenant_id,
request.plan
)
logger.info(
"subscription_reactivated",
tenant_id=request.tenant_id,
user_id=current_user.get("sub"),
new_plan=request.plan
)
return {
"success": result["success"],
"message": result["message"],
"status": result["status"],
"plan": result["plan"],
"next_billing_date": result["next_billing_date"]
}
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"
)
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:
# Use service layer instead of direct database access
subscription_service = SubscriptionService(db)
result = await subscription_service.get_subscription_status(tenant_id)
return SubscriptionStatusResponse(
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"]
)
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"
)
except HTTPException:
raise
except Exception as e:
logger.error("get_subscription_status_failed", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get subscription status"
)
@router.get("/api/v1/subscriptions/{tenant_id}/invoices", response_model=list[InvoiceResponse])
async def get_tenant_invoices(
tenant_id: str,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get invoice history for a tenant from Stripe
"""
try:
# Use service layer for invoice retrieval
subscription_service = SubscriptionService(db)
payment_service = PaymentService()
invoices_data = await subscription_service.get_tenant_invoices(tenant_id, payment_service)
# Transform to response format
invoices = []
for invoice_data in invoices_data:
invoices.append(InvoiceResponse(
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"]
))
logger.info("invoices_retrieved", tenant_id=tenant_id, count=len(invoices))
return invoices
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"
)
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"
)
# ============================================================================
# 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"),
billing_cycle: Optional[str] = Query(None, description="Billing cycle (monthly/yearly)"),
immediate_change: bool = Query(True, description="Apply change immediately"),
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.
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
"""
try:
# Step 1: 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")
)
# Step 2: Get current subscription to determine billing cycle
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"
)
# 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
)
# Step 4: 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 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))
# Step 5: Invalidate all existing tokens for this tenant
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
)
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))
# Step 6: 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,
"proration_details": upgrade_result.get("proration_details"),
"validation": validation,
"requires_token_refresh": True
}
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=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)}"
)
@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 (starter, professional, enterprise)"),
payment_method_id: str = Query(..., description="Payment method ID from frontend"),
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)"),
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,
None, # Trial period handled by coupon logic
billing_interval,
coupon_code # Pass coupon code for trial period determination
)
return {
"success": True,
"message": "Registration and subscription created successfully",
**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}/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",
**result
}
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
)
# 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)
logger.info("Tenant-independent subscription created successfully",
user_id=request.user_data.get('user_id'),
subscription_id=result.get("subscription_id"),
plan_id=request.plan_id)
return {
"success": True,
"message": "Tenant-independent subscription created successfully",
**result
}
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"
)
@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"
)
@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 with the payment provider and returns
the updated payment method information.
"""
try:
# Use SubscriptionOrchestrationService to update payment method
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.update_payment_method(
tenant_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 result
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"
)
# ============================================================================
# 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"
)
# 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)
)