Files
bakery-ia/services/tenant/app/api/subscription.py
2026-01-15 20:45:49 +01:00

807 lines
32 KiB
Python

"""
Tenant Service API Endpoints for Subscription and Registration
Updated with new atomic registration flow support
"""
import logging
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
from app.core.database import get_db
from app.services.registration_state_service import (
registration_state_service,
RegistrationStateService,
RegistrationState
)
from shared.exceptions.payment_exceptions import (
PaymentServiceError,
SetupIntentError,
SubscriptionCreationFailed,
ThreeDSAuthenticationRequired
)
from shared.exceptions.registration_exceptions import (
RegistrationStateError,
InvalidStateTransitionError
)
# Configure logging
logger = logging.getLogger(__name__)
# Create router
router = APIRouter(prefix="/api/v1/tenants", tags=["tenant"])
async def get_subscription_orchestration_service(
db: AsyncSession = Depends(get_db)
) -> SubscriptionOrchestrationService:
"""Dependency injection for subscription orchestration service"""
return SubscriptionOrchestrationService(db)
async def get_registration_state_service() -> RegistrationStateService:
"""Dependency injection for registration state service"""
return registration_state_service
@router.post("/registration-payment-setup",
response_model=Dict[str, Any],
summary="Initiate registration payment setup")
async def create_registration_payment_setup(
user_data: Dict[str, Any],
request: Request,
orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service),
state_service: RegistrationStateService = Depends(get_registration_state_service)
) -> Dict[str, Any]:
"""
Initiate registration payment setup with SetupIntent-first approach
This is the FIRST step in secure registration flow:
1. Creates payment customer
2. Attaches payment method
3. Creates SetupIntent for verification
4. Returns SetupIntent to frontend for 3DS handling
Args:
user_data: User registration data with payment info
Returns:
Payment setup result (may require 3DS)
Raises:
HTTPException: 400 for validation errors, 500 for server errors
"""
try:
print(f"DEBUG_PRINT: Registration payment setup request received for {user_data.get('email')}")
logger.critical(
"Registration payment setup request received (CRITICAL)",
extra={
"email": user_data.get('email'),
"plan_id": user_data.get('plan_id')
}
)
# Validate required fields
if not user_data.get('email'):
logger.error("Registration payment setup failed: Email missing")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required"
)
if not user_data.get('payment_method_id'):
logger.error("Registration payment setup failed: Payment method ID missing", extra={"email": user_data.get('email')})
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Payment method ID is required"
)
if not user_data.get('plan_id'):
logger.error("Registration payment setup failed: Plan ID missing", extra={"email": user_data.get('email')})
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Plan ID is required"
)
# Create registration state
print(f"DEBUG_PRINT: Creating registration state for {user_data['email']}")
logger.critical("Creating registration state", extra={"email": user_data['email']})
state_id = await state_service.create_registration_state(
email=user_data['email'],
user_data=user_data
)
logger.critical("Registration state created", extra={"state_id": state_id, "email": user_data['email']})
# Initiate payment setup
print(f"DEBUG_PRINT: Calling orchestration service for {user_data['email']}")
logger.critical("Calling orchestration service for payment setup", extra={"state_id": state_id, "email": user_data['email']})
result = await orchestration_service.create_registration_payment_setup(
user_data=user_data,
plan_id=user_data.get('plan_id', 'professional'),
payment_method_id=user_data.get('payment_method_id'),
billing_interval=user_data.get('billing_cycle', 'monthly'),
coupon_code=user_data.get('coupon_code')
)
logger.critical("Payment orchestration completed", extra={"state_id": state_id, "email": user_data['email'], "requires_action": result.get('requires_action')})
# Update state with payment setup results
# Note: setup_intent_id may not be present if no 3DS was required
await state_service.update_state_context(state_id, {
'setup_intent_id': result.get('setup_intent_id'),
'subscription_id': result.get('subscription_id'),
'customer_id': result.get('customer_id'),
'payment_customer_id': result.get('payment_customer_id'),
'payment_method_id': result.get('payment_method_id')
})
# Transition to payment verification pending state
await state_service.transition_state(
state_id,
RegistrationState.PAYMENT_VERIFICATION_PENDING
)
logger.critical(
"Registration payment setup flow successful",
extra={
"email": user_data.get('email'),
"state_id": state_id
}
)
return {
"success": True,
"requires_action": result.get('requires_action', False),
"action_type": result.get('action_type'),
"client_secret": result.get('client_secret'),
"setup_intent_id": result.get('setup_intent_id'),
"customer_id": result.get('customer_id'),
"payment_customer_id": result.get('payment_customer_id'),
"plan_id": result.get('plan_id'),
"payment_method_id": result.get('payment_method_id'),
"subscription_id": result.get('subscription_id'),
"billing_cycle": result.get('billing_cycle'),
"email": result.get('email'),
"state_id": state_id,
"message": result.get('message') or "Payment setup completed successfully."
}
except ThreeDSAuthenticationRequired as e:
# 3DS authentication required - return SetupIntent data for frontend
logger.info(f"3DS authentication required for registration: email={user_data.get('email')}, setup_intent_id={e.setup_intent_id}", extra={"email": user_data.get('email'), "setup_intent_id": e.setup_intent_id})
# Update state with payment setup results
await state_service.update_state_context(state_id, {
'setup_intent_id': e.setup_intent_id,
'customer_id': e.extra_data.get('customer_id'),
'payment_customer_id': e.extra_data.get('customer_id'),
'payment_method_id': e.extra_data.get('payment_method_id')
})
# Transition to payment verification pending state
await state_service.transition_state(
state_id,
RegistrationState.PAYMENT_VERIFICATION_PENDING
)
return {
"success": True,
"requires_action": True,
"action_type": e.action_type,
"client_secret": e.client_secret,
"setup_intent_id": e.setup_intent_id,
"subscription_id": e.extra_data.get('subscription_id'),
"customer_id": e.extra_data.get('customer_id'),
"payment_customer_id": e.extra_data.get('customer_id'),
"plan_id": e.extra_data.get('plan_id'),
"payment_method_id": e.extra_data.get('payment_method_id'),
"billing_cycle": e.extra_data.get('billing_interval'),
"email": e.extra_data.get('email'),
"state_id": state_id,
"message": e.extra_data.get('message') or "Payment verification required. Frontend must confirm SetupIntent to handle 3DS."
}
except PaymentServiceError as e:
logger.error(f"Payment service error in registration setup: {str(e)}, email: {user_data.get('email')}",
extra={"email": user_data.get('email')},
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Payment setup failed: {str(e)}"
) from e
except RegistrationStateError as e:
logger.error(f"Registration state error in payment setup: {str(e)}, email: {user_data.get('email')}",
extra={"email": user_data.get('email')},
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration state error: {str(e)}"
) from e
except Exception as e:
logger.error(f"Unexpected error in registration payment setup: {str(e)}, email: {user_data.get('email')}",
extra={"email": user_data.get('email')},
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration payment setup failed: {str(e)}"
) from e
@router.post("/verify-and-complete-registration",
response_model=Dict[str, Any],
summary="Complete registration after 3DS verification")
async def verify_and_complete_registration(
verification_data: Dict[str, Any],
request: Request,
orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service),
state_service: RegistrationStateService = Depends(get_registration_state_service)
) -> Dict[str, Any]:
"""
Complete registration after frontend confirms SetupIntent (3DS handled)
This is the SECOND step in registration architecture:
1. Verifies SetupIntent status
2. Creates subscription with verified payment method
3. Updates registration state
Args:
verification_data: SetupIntent verification data
Returns:
Complete registration result with subscription
Raises:
HTTPException: 400 for validation errors, 500 for server errors
"""
try:
# Validate required fields
if not verification_data.get('setup_intent_id'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="SetupIntent ID is required"
)
if not verification_data.get('user_data'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User data is required"
)
setup_intent_id = verification_data['setup_intent_id']
user_data = verification_data['user_data']
state_id = verification_data.get('state_id')
logger.info(
"Completing registration after SetupIntent verification",
extra={
"email": user_data.get('email'),
"setup_intent_id": setup_intent_id
}
)
# Get registration state if provided
if state_id:
try:
registration_state = await state_service.get_registration_state(state_id)
logger.info(
"Retrieved registration state",
extra={
"state_id": state_id,
"current_state": registration_state['current_state']
}
)
except RegistrationStateError:
logger.warning("Registration state not found, proceeding without state tracking",
extra={"state_id": state_id})
state_id = None
# First verify the setup intent to get the actual customer_id and payment_method_id
verification_result = await orchestration_service.verify_setup_intent_for_registration(
setup_intent_id
)
if not verification_result.get('verified', False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="SetupIntent verification failed"
)
# Extract actual values from verification result
actual_customer_id = verification_result.get('customer_id', user_data.get('customer_id', ''))
actual_payment_method_id = verification_result.get('payment_method_id', user_data.get('payment_method_id', ''))
# Get trial period from coupon if available
trial_period_days = user_data.get('trial_period_days', 0)
coupon_code = user_data.get('coupon_code')
# If we have a coupon code but no trial period, redeem the coupon to get trial days
if coupon_code and trial_period_days == 0:
try:
from app.services.coupon_service import CouponService
coupon_service = CouponService(db_session)
success, discount_applied, error = await coupon_service.redeem_coupon(
coupon_code,
None, # No tenant_id yet
base_trial_days=0
)
if success and discount_applied:
trial_period_days = discount_applied.get("total_trial_days", 0)
logger.info("Retrieved trial period from coupon for verification",
extra={"coupon_code": coupon_code, "trial_period_days": trial_period_days})
except Exception as e:
logger.error("Failed to redeem coupon during verification, using default trial period",
extra={"coupon_code": coupon_code, "error": str(e)},
exc_info=True)
# Fall back to 0 if coupon redemption fails
trial_period_days = 0
# Check if a subscription already exists for this customer
existing_subscriptions = await orchestration_service.get_subscriptions_by_customer_id(actual_customer_id)
if existing_subscriptions:
# If we already have a trial subscription, update it instead of creating a new one
existing_subscription = existing_subscriptions[0] # Get the first subscription
logger.info("Found existing subscription, updating with verified payment method",
extra={
"customer_id": actual_customer_id,
"subscription_id": existing_subscription.provider_subscription_id,
"existing_status": existing_subscription.status
})
# Update the existing subscription with the verified payment method
result = await orchestration_service.update_subscription_with_verified_payment(
existing_subscription.provider_subscription_id,
actual_customer_id,
actual_payment_method_id,
trial_period_days
)
else:
# No existing subscription, create a new one
result = await orchestration_service.complete_subscription_after_setup_intent(
setup_intent_id,
actual_customer_id,
user_data.get('plan_id', 'starter'),
actual_payment_method_id, # Use the verified payment method ID
trial_period_days, # Use the trial period we obtained (90 for PILOT2025)
user_data.get('user_id') if user_data.get('user_id') else None, # Convert empty string to None
user_data.get('billing_interval', 'monthly')
)
# Update registration state if tracking
if state_id:
try:
await state_service.update_state_context(state_id, {
'subscription_id': result['subscription_id'],
'status': result['status']
})
await state_service.transition_state(
state_id,
RegistrationState.SUBSCRIPTION_CREATED
)
logger.info(
"Registration state updated after subscription creation",
extra={
"state_id": state_id,
"subscription_id": result['subscription_id']
}
)
except Exception as e:
logger.error("Failed to update registration state after subscription creation",
extra={
"error": str(e),
"state_id": state_id
},
exc_info=True)
logger.info("Registration completed successfully after 3DS verification",
extra={
"email": user_data.get('email'),
"subscription_id": result['subscription_id']
})
return {
"success": True,
"subscription_id": result['subscription_id'],
"customer_id": result['customer_id'],
"payment_customer_id": result.get('payment_customer_id', result['customer_id']),
"status": result['status'],
"plan_id": result.get('plan_id', result.get('plan')),
"payment_method_id": result.get('payment_method_id'),
"trial_period_days": result.get('trial_period_days'),
"current_period_end": result.get('current_period_end'),
"state_id": state_id,
"message": "Registration completed successfully after 3DS verification"
}
except SetupIntentError as e:
logger.error("SetupIntent verification failed",
extra={
"error": str(e),
"setup_intent_id": setup_intent_id,
"email": user_data.get('email')
},
exc_info=True)
# Mark registration as failed if state tracking
if state_id:
try:
await state_service.mark_registration_failed(
state_id,
f"SetupIntent verification failed: {str(e)}"
)
except Exception:
pass # Don't fail main operation for state tracking failure
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"SetupIntent verification failed: {str(e)}"
) from e
except SubscriptionCreationFailed as e:
logger.error("Subscription creation failed after verification",
extra={
"error": str(e),
"setup_intent_id": setup_intent_id,
"email": user_data.get('email')
},
exc_info=True)
# Mark registration as failed if state tracking
if state_id:
try:
await state_service.mark_registration_failed(
state_id,
f"Subscription creation failed: {str(e)}"
)
except Exception:
pass # Don't fail main operation for state tracking failure
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Subscription creation failed: {str(e)}"
) from e
except Exception as e:
logger.error("Unexpected error in registration completion",
extra={
"error": str(e),
"setup_intent_id": setup_intent_id,
"email": user_data.get('email')
},
exc_info=True)
# Mark registration as failed if state tracking
if state_id:
try:
await state_service.mark_registration_failed(
state_id,
f"Registration completion failed: {str(e)}"
)
except Exception:
pass # Don't fail main operation for state tracking failure
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration completion failed: {str(e)}"
) from e
@router.get("/registration-state/{state_id}",
response_model=Dict[str, Any],
summary="Get registration state")
async def get_registration_state(
state_id: str,
request: Request,
state_service: RegistrationStateService = Depends(get_registration_state_service)
) -> Dict[str, Any]:
"""
Get registration state by ID
Args:
state_id: Registration state ID
Returns:
Registration state data
Raises:
HTTPException: 404 if state not found, 500 for server errors
"""
try:
logger.info("Getting registration state",
extra={"state_id": state_id})
state_data = await state_service.get_registration_state(state_id)
logger.info("Registration state retrieved",
extra={
"state_id": state_id,
"current_state": state_data['current_state']
})
return {
"state_id": state_data['state_id'],
"email": state_data['email'],
"current_state": state_data['current_state'],
"created_at": state_data['created_at'],
"updated_at": state_data['updated_at'],
"setup_intent_id": state_data.get('setup_intent_id'),
"customer_id": state_data.get('customer_id'),
"subscription_id": state_data.get('subscription_id'),
"error": state_data.get('error'),
"user_data": state_data.get('user_data')
}
except RegistrationStateError as e:
logger.error("Registration state not found",
extra={
"error": str(e),
"state_id": state_id
})
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Registration state not found: {str(e)}"
) from e
except Exception as e:
logger.error("Unexpected error getting registration state",
extra={
"error": str(e),
"state_id": state_id
},
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get registration state: {str(e)}"
) from e
# ============================================================================
# SUBSCRIPTION MANAGEMENT ENDPOINTS
# These endpoints handle ongoing subscription management (upgrade, cancel, etc.)
# ============================================================================
@router.post("/subscriptions/cancel",
response_model=Dict[str, Any],
summary="Cancel subscription - Downgrade to read-only mode")
async def cancel_subscription(
request: Request,
tenant_id: str = Query(..., description="Tenant ID"),
reason: str = Query("", description="Cancellation reason"),
orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service)
) -> Dict[str, Any]:
"""
Cancel a subscription and set to read-only mode
This endpoint allows users to cancel their subscription, which will:
- Mark subscription as pending cancellation
- Set read-only mode effective date to end of current billing period
- Allow read-only access until the end of paid period
Args:
tenant_id: Tenant ID to cancel subscription for
reason: Optional cancellation reason
Returns:
Dictionary with cancellation details including effective date
Raises:
HTTPException: 404 if subscription not found, 400 for validation errors
"""
try:
result = await orchestration_service.orchestrate_subscription_cancellation(
tenant_id,
reason
)
return {
"success": True,
"message": result.get("message", "Subscription cancellation initiated"),
"status": result.get("status", "pending_cancellation"),
"cancellation_effective_date": result.get("cancellation_effective_date"),
"days_remaining": result.get("days_remaining"),
"read_only_mode_starts": result.get("read_only_mode_starts")
}
except Exception as e:
logger.error("Failed to cancel subscription",
extra={
"error": str(e),
"tenant_id": tenant_id
},
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to cancel subscription: {str(e)}"
) from e
@router.post("/subscriptions/{tenant_id}/upgrade",
response_model=Dict[str, Any],
summary="Upgrade subscription plan")
async def upgrade_subscription_plan(
tenant_id: str,
request: Request,
new_plan: str = Query(..., description="New plan name"),
orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service)
) -> Dict[str, Any]:
"""
Upgrade a tenant's subscription plan
Validates the upgrade and updates the subscription if eligible.
Args:
tenant_id: Tenant ID
new_plan: New plan name (starter, professional, enterprise)
Returns:
Dictionary with upgrade result
Raises:
HTTPException: 400 if upgrade not allowed, 404 if tenant not found
"""
try:
# Perform the upgrade (validation is built into the orchestration)
result = await orchestration_service.orchestrate_plan_upgrade(tenant_id, new_plan)
return {
"success": True,
"message": result.get("message", f"Plan successfully upgraded to {new_plan}"),
"new_plan": new_plan,
"old_plan": result.get("old_plan"),
"proration_details": result.get("proration_details"),
"next_billing_date": result.get("next_billing_date"),
"billing_cycle": result.get("billing_cycle")
}
except Exception as e:
logger.error("Failed to upgrade subscription plan",
extra={
"error": str(e),
"tenant_id": tenant_id,
"new_plan": new_plan
},
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to upgrade subscription plan: {str(e)}"
) from e
@router.get("/subscriptions/{tenant_id}/validate-upgrade/{new_plan}",
response_model=Dict[str, Any],
summary="Validate plan upgrade eligibility")
async def validate_plan_upgrade(
tenant_id: str,
new_plan: str,
orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service)
) -> Dict[str, Any]:
"""
Validate if a tenant can upgrade to a new plan
Checks plan hierarchy and subscription status before allowing upgrade.
Args:
tenant_id: Tenant ID
new_plan: Plan to validate upgrade to
Returns:
Dictionary with validation result and reason if not allowed
"""
try:
validation = await orchestration_service.validate_plan_upgrade(tenant_id, new_plan)
return validation
except Exception as e:
logger.error("Failed to validate plan upgrade",
extra={
"error": str(e),
"tenant_id": tenant_id,
"new_plan": new_plan
},
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to validate plan upgrade: {str(e)}"
) from e
@router.get("/{tenant_id}/subscriptions/status",
response_model=Dict[str, Any],
summary="Get subscription status")
async def get_subscription_status(
tenant_id: str,
db: AsyncSession = Depends(get_db)
) -> Dict[str, Any]:
"""
Get subscription status for read-only mode enforcement
"""
try:
from sqlalchemy import select
from app.models.tenants import Subscription
# Find active subscription for tenant
result = await db.execute(
select(Subscription).where(
Subscription.tenant_id == tenant_id,
Subscription.status == "active"
)
)
subscription = result.scalars().first()
if subscription:
return {
"status": subscription.status,
"plan": subscription.plan,
"is_read_only": False,
"cancellation_effective_date": subscription.cancellation_effective_date.isoformat() if subscription.cancellation_effective_date else None
}
else:
# No active subscription found
return {
"status": "inactive",
"plan": None,
"is_read_only": True,
"cancellation_effective_date": None
}
except Exception as e:
logger.error(f"Failed to get subscription status: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get subscription status: {str(e)}"
) from e
@router.get("/setup-intents/{setup_intent_id}/verify",
response_model=Dict[str, Any],
summary="Verify SetupIntent status for registration")
async def verify_setup_intent(
setup_intent_id: str,
request: Request,
orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service)
) -> Dict[str, Any]:
"""
Verify SetupIntent status for registration flow
This endpoint verifies the status of a SetupIntent that was created
during the registration payment setup flow.
Args:
setup_intent_id: The SetupIntent ID to verify
Returns:
Dictionary with verification result including status and any required actions
Raises:
HTTPException: 404 if SetupIntent not found, 500 for server errors
"""
try:
logger.info("Verifying SetupIntent for registration",
extra={"setup_intent_id": setup_intent_id})
# Verify the SetupIntent status via orchestration service
result = await orchestration_service.verify_setup_intent_for_registration(setup_intent_id)
logger.info("SetupIntent verification completed",
extra={
"setup_intent_id": setup_intent_id,
"status": result.get('status')
})
return {
"success": True,
"setup_intent_id": setup_intent_id,
"status": result.get('status'),
"payment_method_id": result.get('payment_method_id'),
"customer_id": result.get('customer_id'),
"requires_action": result.get('requires_action', False),
"action_type": result.get('action_type'),
"client_secret": result.get('client_secret'),
"message": result.get('message', 'SetupIntent verification completed successfully')
}
except Exception as e:
logger.error("SetupIntent verification failed",
extra={
"error": str(e),
"setup_intent_id": setup_intent_id
},
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"SetupIntent verification failed: {str(e)}"
) from e