""" 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