""" Subscription Orchestration Service - Coordinator High-level business workflow coordination for subscription operations This service orchestrates complex workflows involving multiple services """ import structlog from typing import Dict, Any, Optional from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService from app.services.coupon_service import CouponService from app.services.tenant_service import EnhancedTenantService from app.core.config import settings from shared.database.exceptions import DatabaseError, ValidationError from shared.database.base import create_database_manager from shared.exceptions.payment_exceptions import SubscriptionUpdateFailed from shared.exceptions.subscription_exceptions import SubscriptionNotFound logger = structlog.get_logger() class SubscriptionOrchestrationService: """Service for orchestrating complex subscription workflows""" def __init__(self, db_session: AsyncSession): self.db_session = db_session self.subscription_service = SubscriptionService(db_session) self.payment_service = PaymentService() # Create a synchronous session for coupon operations # Note: CouponService requires sync Session, not AsyncSession # This is a limitation that should be addressed in future refactoring self.coupon_service = None # Will be initialized when needed with sync session # Initialize tenant service database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") self.tenant_service = EnhancedTenantService(database_manager) async def orchestrate_subscription_creation( self, tenant_id: str, user_data: Dict[str, Any], plan_id: str, payment_method_id: str, billing_interval: str = "monthly", coupon_code: Optional[str] = None ) -> Dict[str, Any]: """ Orchestrate the complete subscription creation workflow Args: tenant_id: Tenant ID user_data: User data for customer creation plan_id: Subscription plan ID payment_method_id: Payment method ID from provider billing_interval: Billing interval (monthly/yearly) coupon_code: Optional coupon code Returns: Dictionary with subscription creation results """ try: logger.info("Starting subscription creation orchestration", tenant_id=tenant_id, plan_id=plan_id) # Step 1: Create customer in payment provider logger.info("Creating customer in payment provider", tenant_id=tenant_id, email=user_data.get('email')) email = user_data.get('email') name = f"{user_data.get('first_name', '')} {user_data.get('last_name', '')}".strip() metadata = None customer = await self.payment_service.create_customer(email, name, metadata) logger.info("Customer created successfully", customer_id=customer.id, tenant_id=tenant_id) # Step 2: Handle coupon logic (if provided) trial_period_days = 0 coupon_discount = None if coupon_code: logger.info("Validating and redeeming coupon code", coupon_code=coupon_code, tenant_id=tenant_id) coupon_service = CouponService(self.db_session) success, discount_applied, error = await coupon_service.redeem_coupon( coupon_code, tenant_id, base_trial_days=0 ) if success and discount_applied: coupon_discount = discount_applied trial_period_days = discount_applied.get("total_trial_days", 0) logger.info("Coupon redeemed successfully", coupon_code=coupon_code, trial_period_days=trial_period_days, discount_applied=discount_applied) else: logger.warning("Failed to redeem coupon, continuing without it", coupon_code=coupon_code, error=error) # Step 3: Create subscription in payment provider logger.info("Creating subscription in payment provider", customer_id=customer.id, plan_id=plan_id, trial_period_days=trial_period_days) # Get the Stripe price ID for this plan price_id = self.payment_service._get_stripe_price_id(plan_id, billing_interval) stripe_subscription = await self.payment_service.create_subscription_with_verified_payment( customer.id, price_id, payment_method_id, trial_period_days if trial_period_days > 0 else None, billing_interval ) logger.info("Subscription created in payment provider", subscription_id=stripe_subscription.id, status=stripe_subscription.status) # Step 4: Create local subscription record logger.info("Creating local subscription record", tenant_id=tenant_id, subscription_id=stripe_subscription.id) subscription_record = await self.subscription_service.create_subscription_record( tenant_id, stripe_subscription.id, customer.id, plan_id, stripe_subscription.status, trial_period_days if trial_period_days > 0 else None, billing_interval ) logger.info("Local subscription record created", subscription_id=stripe_subscription.id) # Step 5: Update tenant with subscription information logger.info("Updating tenant with subscription information", tenant_id=tenant_id) tenant_update_data = { 'customer_id': customer.id, 'subscription_status': stripe_subscription.status, 'subscription_plan': plan_id, 'subscription_tier': plan_id, 'billing_cycle': billing_interval, 'trial_period_days': trial_period_days } await self.tenant_service.update_tenant_subscription_info( tenant_id, tenant_update_data ) logger.info("Tenant updated with subscription information", tenant_id=tenant_id) # Prepare final result # Convert current_period_end timestamp to ISO format if it's an integer current_period_end = stripe_subscription.current_period_end if isinstance(current_period_end, int): # Stripe returns Unix timestamp, convert to datetime then ISO format current_period_end = datetime.fromtimestamp(current_period_end, tz=timezone.utc).isoformat() elif hasattr(current_period_end, 'isoformat'): current_period_end = current_period_end.isoformat() else: current_period_end = str(current_period_end) result = { "success": True, "customer_id": customer.id, "subscription_id": stripe_subscription.id, "status": stripe_subscription.status, "plan": plan_id, "billing_cycle": billing_interval, "trial_period_days": trial_period_days, "current_period_end": current_period_end, "coupon_applied": bool(coupon_discount) } if coupon_discount: result["coupon_details"] = coupon_discount logger.info("Subscription creation orchestration completed successfully", tenant_id=tenant_id, subscription_id=stripe_subscription.id) return result except ValidationError as ve: logger.error("Subscription creation validation failed", error=str(ve), tenant_id=tenant_id) raise ve except Exception as e: logger.error("Subscription creation orchestration failed", error=str(e), tenant_id=tenant_id) raise DatabaseError(f"Failed to create subscription: {str(e)}") async def create_tenant_independent_subscription( self, user_data: Dict[str, Any], plan_id: str, payment_method_id: str, billing_interval: str = "monthly", coupon_code: Optional[str] = None ) -> Dict[str, Any]: """ Create a subscription that is not linked to any tenant yet This subscription will be linked to a tenant during onboarding when the user creates their bakery/tenant. Args: user_data: User data for customer creation plan_id: Subscription plan ID payment_method_id: Payment method ID from provider billing_interval: Billing interval (monthly/yearly) coupon_code: Optional coupon code Returns: Dictionary with subscription creation results """ try: logger.info("Starting tenant-independent subscription creation", user_id=user_data.get('user_id'), plan_id=plan_id) # Step 1: Create customer in payment provider logger.info("Creating customer in payment provider", user_id=user_data.get('user_id'), email=user_data.get('email')) email = user_data.get('email') name = f"{user_data.get('first_name', '')} {user_data.get('last_name', '')}".strip() metadata = None customer = await self.payment_service.create_customer(email, name, metadata) logger.info("Customer created successfully", customer_id=customer.id, user_id=user_data.get('user_id')) # Step 2: Handle coupon logic (if provided) trial_period_days = 0 coupon_discount = None if coupon_code: logger.info("Validating and redeeming coupon code", coupon_code=coupon_code, user_id=user_data.get('user_id')) coupon_service = CouponService(self.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: coupon_discount = discount_applied trial_period_days = discount_applied.get("total_trial_days", 0) logger.info("Coupon redeemed successfully", coupon_code=coupon_code, trial_period_days=trial_period_days, discount_applied=discount_applied) else: logger.warning("Failed to redeem coupon, continuing without it", coupon_code=coupon_code, error=error) # Step 3: Create subscription in payment provider (or get SetupIntent for 3DS) logger.info("Creating subscription in payment provider", customer_id=customer.id, plan_id=plan_id, trial_period_days=trial_period_days) # Get the Stripe price ID for this plan price_id = self.payment_service._get_stripe_price_id(plan_id, billing_interval) subscription_result = await self.payment_service.create_subscription_with_verified_payment( customer.id, price_id, payment_method_id, trial_period_days if trial_period_days > 0 else None, billing_interval ) # Check if result requires 3DS authentication (SetupIntent confirmation) if isinstance(subscription_result, dict) and subscription_result.get('requires_action'): logger.info("Subscription creation requires SetupIntent confirmation", customer_id=customer.id, action_type=subscription_result.get('action_type'), setup_intent_id=subscription_result.get('setup_intent_id')) # Return the SetupIntent data for frontend to handle 3DS return { "requires_action": True, "action_type": subscription_result.get('action_type'), "client_secret": subscription_result.get('client_secret'), "setup_intent_id": subscription_result.get('setup_intent_id'), "customer_id": customer.id, "payment_method_id": payment_method_id, "plan_id": plan_id, "trial_period_days": trial_period_days, "billing_interval": billing_interval, "message": subscription_result.get('message'), "user_id": user_data.get('user_id') } # Extract subscription object from result # Result can be either: # 1. A dict with 'subscription' key containing an object # 2. A dict with subscription fields directly (subscription_id, status, etc.) # 3. A subscription object directly if isinstance(subscription_result, dict): if 'subscription' in subscription_result: stripe_subscription = subscription_result['subscription'] elif 'subscription_id' in subscription_result: # Create a simple object-like wrapper for dict results class SubscriptionWrapper: def __init__(self, data: dict): self.id = data.get('subscription_id') self.status = data.get('status') self.current_period_start = data.get('current_period_start') self.current_period_end = data.get('current_period_end') self.customer = data.get('customer_id') stripe_subscription = SubscriptionWrapper(subscription_result) else: stripe_subscription = subscription_result else: stripe_subscription = subscription_result logger.info("Subscription created in payment provider", subscription_id=stripe_subscription.id, status=stripe_subscription.status) # Step 4: Create local subscription record WITHOUT tenant_id logger.info("Creating tenant-independent subscription record", user_id=user_data.get('user_id'), subscription_id=stripe_subscription.id) subscription_record = await self.subscription_service.create_tenant_independent_subscription_record( stripe_subscription.id, customer.id, plan_id, stripe_subscription.status, trial_period_days if trial_period_days > 0 else None, billing_interval, user_data.get('user_id') ) logger.info("Tenant-independent subscription record created", subscription_id=stripe_subscription.id, user_id=user_data.get('user_id')) # Prepare final result # Convert current_period_end timestamp to ISO format if it's an integer current_period_end = stripe_subscription.current_period_end if isinstance(current_period_end, int): # Stripe returns Unix timestamp, convert to datetime then ISO format current_period_end = datetime.fromtimestamp(current_period_end, tz=timezone.utc).isoformat() elif hasattr(current_period_end, 'isoformat'): current_period_end = current_period_end.isoformat() else: current_period_end = str(current_period_end) result = { "success": True, "customer_id": customer.id, "subscription_id": stripe_subscription.id, "status": stripe_subscription.status, "plan": plan_id, "billing_cycle": billing_interval, "trial_period_days": trial_period_days, "current_period_end": current_period_end, "coupon_applied": bool(coupon_discount), "user_id": user_data.get('user_id') } if coupon_discount: result["coupon_details"] = coupon_discount logger.info("Tenant-independent subscription creation completed successfully", user_id=user_data.get('user_id'), subscription_id=stripe_subscription.id) return result except ValidationError as ve: logger.error("Tenant-independent subscription creation validation failed", error=str(ve), user_id=user_data.get('user_id')) raise ve except Exception as e: logger.error("Tenant-independent subscription creation failed", error=str(e), user_id=user_data.get('user_id')) raise DatabaseError(f"Failed to create tenant-independent subscription: {str(e)}") async def complete_subscription_after_setup_intent( self, setup_intent_id: str, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int], user_id: str, billing_interval: str = "monthly" ) -> Dict[str, Any]: """ Complete subscription creation after SetupIntent has been confirmed This method is called after the frontend successfully confirms a SetupIntent (with or without 3DS). It creates the subscription with the verified payment method and creates a database record. Args: setup_intent_id: The confirmed SetupIntent ID customer_id: Stripe customer ID plan_id: Subscription plan ID payment_method_id: Verified payment method ID trial_period_days: Optional trial period user_id: User ID for linking billing_interval: Billing interval Returns: Dictionary with subscription details """ try: logger.info("Completing subscription after SetupIntent confirmation", setup_intent_id=setup_intent_id, user_id=user_id, plan_id=plan_id) # Call payment service to complete subscription creation result = await self.payment_service.complete_subscription_after_setup_intent( setup_intent_id, customer_id, plan_id, payment_method_id, trial_period_days ) stripe_subscription = result['subscription'] logger.info("Subscription created in payment provider after SetupIntent", subscription_id=stripe_subscription.id, status=stripe_subscription.status) # Create local subscription record WITHOUT tenant_id (tenant-independent) subscription_record = await self.subscription_service.create_tenant_independent_subscription_record( stripe_subscription.id, customer_id, plan_id, stripe_subscription.status, trial_period_days, billing_interval, user_id ) logger.info("Tenant-independent subscription record created after SetupIntent", subscription_id=stripe_subscription.id, user_id=user_id) # Convert current_period_end to ISO format current_period_end = stripe_subscription.current_period_end if isinstance(current_period_end, int): current_period_end = datetime.fromtimestamp(current_period_end, tz=timezone.utc).isoformat() elif hasattr(current_period_end, 'isoformat'): current_period_end = current_period_end.isoformat() else: current_period_end = str(current_period_end) return { "success": True, "customer_id": customer_id, "subscription_id": stripe_subscription.id, "status": stripe_subscription.status, "plan": plan_id, "billing_cycle": billing_interval, "trial_period_days": trial_period_days, "current_period_end": current_period_end, "user_id": user_id, "setup_intent_id": setup_intent_id } except Exception as e: logger.error("Failed to complete subscription after SetupIntent", error=str(e), setup_intent_id=setup_intent_id, user_id=user_id) raise DatabaseError(f"Failed to complete subscription: {str(e)}") async def orchestrate_subscription_cancellation( self, tenant_id: str, reason: str = "" ) -> Dict[str, Any]: """ Orchestrate the complete subscription cancellation workflow Args: tenant_id: Tenant ID to cancel subscription for reason: Optional cancellation reason Returns: Dictionary with cancellation details """ try: logger.info("Starting subscription cancellation orchestration", tenant_id=tenant_id, reason=reason) # Step 1: Cancel in subscription service (database status update) cancellation_result = await self.subscription_service.cancel_subscription( tenant_id, reason ) logger.info("Subscription cancelled in database", tenant_id=tenant_id, status=cancellation_result["status"]) # Step 2: Get the subscription to find Stripe subscription ID subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id) if subscription and subscription.subscription_id: # Step 3: Cancel in payment provider stripe_subscription = await self.payment_service.cancel_payment_subscription( subscription.subscription_id ) logger.info("Subscription cancelled in payment provider", subscription_id=stripe_subscription.id, stripe_status=stripe_subscription.status) # Step 4: Sync status back to database await self.subscription_service.update_subscription_status( tenant_id, stripe_subscription.status, { 'current_period_end': stripe_subscription.current_period_end } ) # Step 5: Update tenant status tenant_update_data = { 'subscription_status': 'pending_cancellation', 'subscription_cancelled_at': datetime.now(timezone.utc) } await self.tenant_service.update_tenant_subscription_info( tenant_id, tenant_update_data ) logger.info("Tenant subscription status updated", tenant_id=tenant_id) return cancellation_result except ValidationError as ve: logger.error("Subscription cancellation validation failed", error=str(ve), tenant_id=tenant_id) raise ve except Exception as e: logger.error("Subscription cancellation orchestration failed", error=str(e), tenant_id=tenant_id) raise DatabaseError(f"Failed to cancel subscription: {str(e)}") async def orchestrate_subscription_reactivation( self, tenant_id: str, plan: str = "starter" ) -> Dict[str, Any]: """ Orchestrate subscription reactivation workflow Args: tenant_id: Tenant ID to reactivate plan: Plan to reactivate with Returns: Dictionary with reactivation details """ try: logger.info("Starting subscription reactivation orchestration", tenant_id=tenant_id, plan=plan) # Step 1: Reactivate in subscription service reactivation_result = await self.subscription_service.reactivate_subscription( tenant_id, plan ) logger.info("Subscription reactivated in database", tenant_id=tenant_id, new_plan=plan) # Step 2: Update tenant status tenant_update_data = { 'subscription_status': 'active', 'subscription_plan': plan, 'subscription_tier': plan, 'subscription_cancelled_at': None } await self.tenant_service.update_tenant_subscription_info( tenant_id, tenant_update_data ) logger.info("Tenant subscription status updated after reactivation", tenant_id=tenant_id) return reactivation_result except ValidationError as ve: logger.error("Subscription reactivation validation failed", error=str(ve), tenant_id=tenant_id) raise ve except Exception as e: logger.error("Subscription reactivation orchestration failed", error=str(e), tenant_id=tenant_id) raise DatabaseError(f"Failed to reactivate subscription: {str(e)}") async def orchestrate_plan_upgrade( self, tenant_id: str, new_plan: str, proration_behavior: str = "create_prorations", immediate_change: bool = False, billing_cycle: str = "monthly" ) -> Dict[str, Any]: """ Orchestrate plan upgrade workflow with proration Args: tenant_id: Tenant ID new_plan: New plan name proration_behavior: Proration behavior immediate_change: Whether to apply changes immediately billing_cycle: Billing cycle for new plan Returns: Dictionary with upgrade results """ try: logger.info("Starting plan upgrade orchestration", tenant_id=tenant_id, new_plan=new_plan, immediate_change=immediate_change) # Step 1: Get current subscription subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id) if not subscription: raise ValidationError(f"Subscription not found for tenant {tenant_id}") if not subscription.subscription_id: raise ValidationError(f"Tenant {tenant_id} does not have a Stripe subscription ID") # Step 2: Get Stripe price ID for new plan stripe_price_id = self.payment_service._get_stripe_price_id(new_plan, billing_cycle) # Step 3: Calculate proration preview proration_details = await self.payment_service.calculate_payment_proration( subscription.subscription_id, stripe_price_id, proration_behavior ) logger.info("Proration calculated for plan upgrade", tenant_id=tenant_id, proration_amount=proration_details.get("net_amount", 0)) # Step 4: Update in payment provider updated_stripe_subscription = await self.payment_service.update_payment_subscription( subscription.subscription_id, stripe_price_id, proration_behavior=proration_behavior, billing_cycle_anchor="now" if immediate_change else "unchanged", payment_behavior="error_if_incomplete", immediate_change=immediate_change ) logger.info("Plan updated in payment provider", subscription_id=updated_stripe_subscription.id, new_status=updated_stripe_subscription.status) # Step 5: Update local subscription record update_result = await self.subscription_service.update_subscription_plan_record( tenant_id, new_plan, updated_stripe_subscription.status, updated_stripe_subscription.current_period_start, updated_stripe_subscription.current_period_end, billing_cycle, proration_details ) logger.info("Local subscription record updated", tenant_id=tenant_id, new_plan=new_plan) # Step 6: Update tenant with new plan information tenant_update_data = { 'subscription_plan': new_plan, 'subscription_tier': new_plan, 'billing_cycle': billing_cycle } await self.tenant_service.update_tenant_subscription_info( tenant_id, tenant_update_data ) logger.info("Tenant plan information updated", tenant_id=tenant_id) # Add immediate_change to result update_result["immediate_change"] = immediate_change return update_result except ValidationError as ve: logger.error("Plan upgrade validation failed", error=str(ve), tenant_id=tenant_id) raise ve except Exception as e: logger.error("Plan upgrade orchestration failed", error=str(e), tenant_id=tenant_id) raise DatabaseError(f"Failed to upgrade plan: {str(e)}") async def orchestrate_billing_cycle_change( self, tenant_id: str, new_billing_cycle: str, proration_behavior: str = "create_prorations" ) -> Dict[str, Any]: """ Orchestrate billing cycle change workflow Args: tenant_id: Tenant ID new_billing_cycle: New billing cycle (monthly/yearly) proration_behavior: Proration behavior Returns: Dictionary with billing cycle change results """ try: logger.info("Starting billing cycle change orchestration", tenant_id=tenant_id, new_billing_cycle=new_billing_cycle) # Step 1: Get current subscription subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id) if not subscription: raise ValidationError(f"Subscription not found for tenant {tenant_id}") if not subscription.subscription_id: raise ValidationError(f"Tenant {tenant_id} does not have a Stripe subscription ID") # Step 2: Change billing cycle in payment provider updated_stripe_subscription = await self.payment_service.change_billing_cycle( subscription.subscription_id, new_billing_cycle, proration_behavior ) logger.info("Billing cycle changed in payment provider", subscription_id=updated_stripe_subscription.id, new_billing_cycle=new_billing_cycle) # Step 3: Get proration details (if available) proration_details = {} # Billing cycle change returns proration info in subscription object # Step 4: Update local subscription record update_result = await self.subscription_service.update_billing_cycle_record( tenant_id, new_billing_cycle, updated_stripe_subscription.status, updated_stripe_subscription.current_period_start, updated_stripe_subscription.current_period_end, subscription.plan, # current_plan proration_details ) logger.info("Local subscription record updated with new billing cycle", tenant_id=tenant_id) # Step 5: Update tenant with new billing cycle tenant_update_data = { 'billing_cycle': new_billing_cycle } await self.tenant_service.update_tenant_subscription_info( tenant_id, tenant_update_data ) logger.info("Tenant billing cycle information updated", tenant_id=tenant_id) return update_result except ValidationError as ve: logger.error("Billing cycle change validation failed", error=str(ve), tenant_id=tenant_id) raise ve except Exception as e: logger.error("Billing cycle change orchestration failed", error=str(e), tenant_id=tenant_id) raise DatabaseError(f"Failed to change billing cycle: {str(e)}") async def orchestrate_coupon_redemption( self, tenant_id: str, coupon_code: str, base_trial_days: int = 14 ) -> Dict[str, Any]: """ Orchestrate coupon redemption workflow Args: tenant_id: Tenant ID coupon_code: Coupon code to redeem base_trial_days: Base trial days without coupon Returns: Dictionary with redemption results """ try: logger.info("Starting coupon redemption orchestration", tenant_id=tenant_id, coupon_code=coupon_code) # Note: CouponService requires sync session # This needs to be refactored to work with async properly # For now, return a simplified response logger.warning("Coupon redemption not fully implemented in orchestration service", tenant_id=tenant_id, coupon_code=coupon_code) return { "success": False, "error": "Coupon redemption requires session refactoring", "coupon_valid": False } except Exception as e: logger.error("Coupon redemption orchestration failed", error=str(e), tenant_id=tenant_id, coupon_code=coupon_code) raise DatabaseError(f"Failed to redeem coupon: {str(e)}") async def handle_payment_webhook( self, event_type: str, event_data: Dict[str, Any] ) -> Dict[str, Any]: """ Handle payment provider webhook events Args: event_type: Webhook event type event_data: Webhook event data Returns: Dictionary with webhook processing results """ try: logger.info("Processing payment webhook event", event_type=event_type, event_id=event_data.get('id')) result = { "event_type": event_type, "processed": True, "actions_taken": [] } # Handle different event types if event_type == 'customer.subscription.created': await self._handle_subscription_created(event_data) result["actions_taken"].append("subscription_created") elif event_type == 'customer.subscription.updated': await self._handle_subscription_updated(event_data) result["actions_taken"].append("subscription_updated") elif event_type == 'customer.subscription.deleted': await self._handle_subscription_deleted(event_data) result["actions_taken"].append("subscription_deleted") elif event_type == 'invoice.payment_succeeded': await self._handle_payment_succeeded(event_data) result["actions_taken"].append("payment_succeeded") elif event_type == 'invoice.payment_failed': await self._handle_payment_failed(event_data) result["actions_taken"].append("payment_failed") elif event_type == 'customer.subscription.trial_will_end': await self._handle_trial_will_end(event_data) result["actions_taken"].append("trial_will_end") elif event_type == 'invoice.payment_action_required': await self._handle_payment_action_required(event_data) result["actions_taken"].append("payment_action_required") elif event_type == 'customer.subscription.paused': await self._handle_subscription_paused(event_data) result["actions_taken"].append("subscription_paused") elif event_type == 'customer.subscription.resumed': await self._handle_subscription_resumed(event_data) result["actions_taken"].append("subscription_resumed") elif event_type == 'payment_intent.succeeded': await self._handle_payment_intent_succeeded(event_data) result["actions_taken"].append("payment_intent_succeeded") elif event_type == 'payment_intent.payment_failed': await self._handle_payment_intent_failed(event_data) result["actions_taken"].append("payment_intent_failed") elif event_type == 'payment_intent.requires_action': await self._handle_payment_intent_requires_action(event_data) result["actions_taken"].append("payment_intent_requires_action") elif event_type == 'setup_intent.succeeded': await self._handle_setup_intent_succeeded(event_data) result["actions_taken"].append("setup_intent_succeeded") elif event_type == 'setup_intent.requires_action': await self._handle_setup_intent_requires_action(event_data) result["actions_taken"].append("setup_intent_requires_action") else: logger.info("Unhandled webhook event type", event_type=event_type) result["processed"] = False logger.info("Webhook event processed successfully", event_type=event_type, actions_taken=result["actions_taken"]) return result except Exception as e: logger.error("Failed to process webhook event", error=str(e), event_type=event_type, event_id=event_data.get('id')) raise DatabaseError(f"Failed to process webhook: {str(e)}") async def _handle_subscription_created(self, event_data: Dict[str, Any]): """Handle subscription created event""" subscription_id = event_data['id'] customer_id = event_data['customer'] status = event_data['status'] logger.info("Handling subscription created event", subscription_id=subscription_id, customer_id=customer_id, status=status) # Find tenant by customer ID tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: # Update subscription status await self.subscription_service.update_subscription_status( str(tenant.id), status, { 'current_period_start': datetime.fromtimestamp(event_data['current_period_start']), 'current_period_end': datetime.fromtimestamp(event_data['current_period_end']) } ) # Update tenant status tenant_update_data = { 'subscription_status': status, 'subscription_created_at': datetime.now(timezone.utc) } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Subscription created event handled", tenant_id=str(tenant.id), subscription_id=subscription_id) async def _handle_subscription_updated(self, event_data: Dict[str, Any]): """Handle subscription updated event""" subscription_id = event_data['id'] status = event_data['status'] logger.info("Handling subscription updated event", subscription_id=subscription_id, status=status) # Find tenant by subscription subscription = await self.subscription_service.get_subscription_by_provider_id(subscription_id) if subscription: # Update subscription status await self.subscription_service.update_subscription_status( subscription.tenant_id, status, { 'current_period_start': datetime.fromtimestamp(event_data['current_period_start']), 'current_period_end': datetime.fromtimestamp(event_data['current_period_end']) } ) # Update tenant status tenant_update_data = { 'subscription_status': status } await self.tenant_service.update_tenant_subscription_info( subscription.tenant_id, tenant_update_data ) logger.info("Subscription updated event handled", tenant_id=subscription.tenant_id, subscription_id=subscription_id) async def _handle_subscription_deleted(self, event_data: Dict[str, Any]): """Handle subscription deleted event""" subscription_id = event_data['id'] logger.info("Handling subscription deleted event", subscription_id=subscription_id) # Find and update subscription subscription = await self.subscription_service.get_subscription_by_provider_id(subscription_id) if subscription: # Cancel subscription in our system await self.subscription_service.cancel_subscription( subscription.tenant_id, "Subscription deleted in payment provider" ) logger.info("Subscription deleted event handled", tenant_id=subscription.tenant_id, subscription_id=subscription_id) async def _handle_payment_succeeded(self, event_data: Dict[str, Any]): """Handle successful payment event""" invoice_id = event_data['id'] subscription_id = event_data.get('subscription') customer_id = event_data['customer'] logger.info("Handling payment succeeded event", invoice_id=invoice_id, subscription_id=subscription_id, customer_id=customer_id) # Find tenant and update payment status tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { 'last_payment_date': datetime.now(timezone.utc), 'payment_status': 'paid' } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Payment succeeded event handled", tenant_id=str(tenant.id), invoice_id=invoice_id) async def _handle_payment_failed(self, event_data: Dict[str, Any]): """Handle failed payment event""" invoice_id = event_data['id'] subscription_id = event_data.get('subscription') customer_id = event_data['customer'] logger.warning("Handling payment failed event", invoice_id=invoice_id, subscription_id=subscription_id, customer_id=customer_id) # Find tenant and update payment status tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { 'payment_status': 'failed', 'last_payment_failure': datetime.now(timezone.utc) } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Payment failed event handled", tenant_id=str(tenant.id), invoice_id=invoice_id) async def _handle_trial_will_end(self, event_data: Dict[str, Any]): """Handle trial will end event (3 days before trial ends)""" subscription_id = event_data['id'] customer_id = event_data['customer'] trial_end = event_data.get('trial_end') logger.info("Handling trial will end event", subscription_id=subscription_id, customer_id=customer_id, trial_end=trial_end) tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { 'trial_ending_soon': True, 'trial_end_notified_at': datetime.now(timezone.utc) } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Trial will end event handled", tenant_id=str(tenant.id), subscription_id=subscription_id) async def _handle_payment_action_required(self, event_data: Dict[str, Any]): """Handle payment action required event (3D Secure, etc.)""" invoice_id = event_data['id'] customer_id = event_data['customer'] subscription_id = event_data.get('subscription') logger.info("Handling payment action required event", invoice_id=invoice_id, customer_id=customer_id, subscription_id=subscription_id) tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { 'payment_action_required': True, 'last_payment_action_required_at': datetime.now(timezone.utc) } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Payment action required event handled", tenant_id=str(tenant.id), invoice_id=invoice_id) async def _handle_subscription_paused(self, event_data: Dict[str, Any]): """Handle subscription paused event""" subscription_id = event_data['id'] customer_id = event_data['customer'] status = 'paused' logger.info("Handling subscription paused event", subscription_id=subscription_id, customer_id=customer_id) tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: await self.subscription_service.update_subscription_status( str(tenant.id), status, { 'paused_at': datetime.now(timezone.utc) } ) tenant_update_data = { 'subscription_status': status, 'paused_at': datetime.now(timezone.utc) } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Subscription paused event handled", tenant_id=str(tenant.id), subscription_id=subscription_id) async def _handle_subscription_resumed(self, event_data: Dict[str, Any]): """Handle subscription resumed event""" subscription_id = event_data['id'] customer_id = event_data['customer'] status = event_data['status'] logger.info("Handling subscription resumed event", subscription_id=subscription_id, customer_id=customer_id) tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: await self.subscription_service.update_subscription_status( str(tenant.id), status, { 'resumed_at': datetime.now(timezone.utc) } ) tenant_update_data = { 'subscription_status': status, 'resumed_at': datetime.now(timezone.utc) } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Subscription resumed event handled", tenant_id=str(tenant.id), subscription_id=subscription_id) async def _handle_payment_intent_succeeded(self, event_data: Dict[str, Any]): """Handle payment intent succeeded event (including 3DS authenticated payments)""" payment_intent_id = event_data['id'] customer_id = event_data.get('customer') amount = event_data.get('amount', 0) / 100.0 currency = event_data.get('currency', 'eur').upper() logger.info("Handling payment intent succeeded event", payment_intent_id=payment_intent_id, customer_id=customer_id, amount=amount) if customer_id: tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { 'payment_action_required': False, 'last_successful_payment_at': datetime.now(timezone.utc) } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Payment intent succeeded event handled", tenant_id=str(tenant.id), payment_intent_id=payment_intent_id) async def _handle_payment_intent_failed(self, event_data: Dict[str, Any]): """Handle payment intent failed event (including 3DS authentication failures)""" payment_intent_id = event_data['id'] customer_id = event_data.get('customer') last_payment_error = event_data.get('last_payment_error', {}) error_message = last_payment_error.get('message', 'Payment failed') logger.warning("Handling payment intent failed event", payment_intent_id=payment_intent_id, customer_id=customer_id, error_message=error_message) if customer_id: tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { 'payment_action_required': False, 'last_payment_failure_at': datetime.now(timezone.utc), 'last_payment_error': error_message } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Payment intent failed event handled", tenant_id=str(tenant.id), payment_intent_id=payment_intent_id) async def _handle_payment_intent_requires_action(self, event_data: Dict[str, Any]): """Handle payment intent requires action event (3DS authentication needed)""" payment_intent_id = event_data['id'] customer_id = event_data.get('customer') next_action = event_data.get('next_action', {}) action_type = next_action.get('type', 'unknown') logger.info("Handling payment intent requires action event", payment_intent_id=payment_intent_id, customer_id=customer_id, action_type=action_type) if customer_id: tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { 'payment_action_required': True, 'payment_action_type': action_type, 'last_payment_action_required_at': datetime.now(timezone.utc) } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Payment intent requires action event handled", tenant_id=str(tenant.id), payment_intent_id=payment_intent_id, action_type=action_type) async def _handle_setup_intent_succeeded(self, event_data: Dict[str, Any]): """Handle setup intent succeeded event (3DS authentication completed)""" setup_intent_id = event_data['id'] customer_id = event_data.get('customer') logger.info("Handling setup intent succeeded event (3DS authentication completed)", setup_intent_id=setup_intent_id, customer_id=customer_id) if customer_id: tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { 'threeds_authentication_completed': True, 'threeds_authentication_completed_at': datetime.now(timezone.utc), 'last_threeds_setup_intent_id': setup_intent_id } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Setup intent succeeded event handled (3DS authentication completed)", tenant_id=str(tenant.id), setup_intent_id=setup_intent_id) async def _handle_setup_intent_requires_action(self, event_data: Dict[str, Any]): """Handle setup intent requires action event (3DS authentication needed)""" setup_intent_id = event_data['id'] customer_id = event_data.get('customer') next_action = event_data.get('next_action', {}) action_type = next_action.get('type', 'unknown') logger.info("Handling setup intent requires action event (3DS authentication needed)", setup_intent_id=setup_intent_id, customer_id=customer_id, action_type=action_type) if customer_id: tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id) if tenant: tenant_update_data = { 'threeds_authentication_required': True, 'threeds_authentication_required_at': datetime.now(timezone.utc), 'last_threeds_setup_intent_id': setup_intent_id, 'threeds_action_type': action_type } await self.tenant_service.update_tenant_subscription_info( str(tenant.id), tenant_update_data ) logger.info("Setup intent requires action event handled (3DS authentication needed)", tenant_id=str(tenant.id), setup_intent_id=setup_intent_id, action_type=action_type) async def orchestrate_subscription_creation_with_default_payment( self, tenant_id: str, user_data: Dict[str, Any], plan_id: str, billing_interval: str = "monthly", coupon_code: Optional[str] = None, payment_method_id: Optional[str] = None ) -> Dict[str, Any]: """ Orchestrate subscription creation using user's default payment method if available This method tries to use the user's default payment method from auth service if no payment_method_id is provided. Falls back to manual payment entry if needed. Args: tenant_id: Tenant ID user_data: User data for customer creation plan_id: Subscription plan ID billing_interval: Billing interval (monthly/yearly) coupon_code: Optional coupon code payment_method_id: Optional payment method ID (if not provided, tries to fetch default) Returns: Dictionary with subscription creation results """ try: logger.info("Starting subscription creation with default payment method", tenant_id=tenant_id, plan_id=plan_id) # Step 0: Try to get user's default payment method if not provided if not payment_method_id: payment_method_id = await self._get_user_default_payment_method(user_data.get('user_id')) if payment_method_id: logger.info("Using user's default payment method for subscription", tenant_id=tenant_id, payment_method_id=payment_method_id) else: logger.info("No default payment method found for user, will create subscription without attached payment method", tenant_id=tenant_id, user_id=user_data.get('user_id')) # Step 1: Create subscription using the existing orchestration method result = await self.orchestrate_subscription_creation( tenant_id, user_data, plan_id, payment_method_id if payment_method_id else '', billing_interval, coupon_code ) logger.info("Subscription creation with default payment completed successfully", tenant_id=tenant_id, subscription_id=result.get('subscription', {}).get('id')) return result except Exception as e: logger.error("Subscription creation with default payment failed", error=str(e), tenant_id=tenant_id, plan_id=plan_id) raise e async def _get_user_default_payment_method(self, user_id: Optional[str]) -> Optional[str]: """ Get user's default payment method from auth service Args: user_id: User ID to fetch payment method for Returns: Payment method ID if found, None otherwise """ if not user_id: logger.warning("Cannot fetch default payment method - no user_id provided") return None try: from app.core.config import settings from shared.clients.auth_client import AuthServiceClient auth_client = AuthServiceClient(settings) user_data = await auth_client.get_user_details(user_id) if user_data and user_data.get('default_payment_method_id'): logger.info("Retrieved user's default payment method from auth service", user_id=user_id, payment_method_id=user_data['default_payment_method_id']) return user_data['default_payment_method_id'] else: logger.info("No default payment method found for user in auth service", user_id=user_id) return None except Exception as e: logger.warning("Failed to retrieve user's default payment method from auth service", user_id=user_id, error=str(e)) # Don't fail the subscription creation if we can't get the default payment method return None async def get_payment_method(self, tenant_id: str) -> Optional[Dict[str, Any]]: """ Get the current payment method for a tenant's subscription This is an orchestration method that coordinates between: 1. SubscriptionService (to get subscription data) 2. PaymentService (to get payment method from provider) Args: tenant_id: Tenant ID Returns: Dictionary with payment method details or None """ try: # Get subscription from database subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id) if not subscription: logger.warning("get_payment_method_no_subscription", tenant_id=tenant_id) return None # Check if subscription has a customer ID if not subscription.customer_id: logger.warning("get_payment_method_no_customer_id", tenant_id=tenant_id) return None # Get payment method from payment provider payment_method = await self.payment_service.get_customer_payment_method(subscription.customer_id) if not payment_method: logger.info("get_payment_method_not_found", tenant_id=tenant_id, customer_id=subscription.customer_id) return None logger.info("payment_method_retrieved", tenant_id=tenant_id, payment_method_type=payment_method.type, last4=payment_method.last4) return { "brand": payment_method.brand, "last4": payment_method.last4, "exp_month": payment_method.exp_month, "exp_year": payment_method.exp_year } except Exception as e: logger.error("get_payment_method_failed", error=str(e), tenant_id=tenant_id) return None async def update_payment_method( self, tenant_id: str, payment_method_id: str ) -> Dict[str, Any]: """ Update the default payment method for a tenant's subscription This is an orchestration method that coordinates between: 1. SubscriptionService (to get subscription data) 2. PaymentService (to update payment method with provider) Args: tenant_id: Tenant ID payment_method_id: New payment method ID from frontend Returns: Dictionary with updated payment method details Raises: ValidationError: If subscription or customer_id not found """ try: # Get subscription from database subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id) if not subscription: raise ValidationError(f"Subscription not found for tenant {tenant_id}") if not subscription.customer_id: raise ValidationError(f"Tenant {tenant_id} does not have a payment customer ID") # Update payment method via payment provider payment_result = await self.payment_service.update_payment_method( subscription.customer_id, payment_method_id ) logger.info("payment_method_updated", tenant_id=tenant_id, payment_method_id=payment_method_id, requires_action=payment_result.get('requires_action', False)) pm_details = payment_result.get('payment_method', {}) return { "success": True, "message": "Payment method updated successfully", "payment_method_id": pm_details.get('id'), "brand": pm_details.get('brand', 'unknown'), "last4": pm_details.get('last4', '0000'), "exp_month": pm_details.get('exp_month'), "exp_year": pm_details.get('exp_year'), "requires_action": payment_result.get('requires_action', False), "client_secret": payment_result.get('client_secret'), "payment_intent_status": payment_result.get('payment_intent_status') } except ValidationError: raise except Exception as e: logger.error("update_payment_method_failed", error=str(e), tenant_id=tenant_id) raise DatabaseError(f"Failed to update payment method: {str(e)}") async def create_registration_payment_setup( self, user_data: Dict[str, Any], plan_id: str, payment_method_id: str, billing_interval: str = "monthly", coupon_code: Optional[str] = None ) -> Dict[str, Any]: """ Create payment customer and SetupIntent for registration (pre-user-creation) This method supports the secure architecture where users are only created after payment verification. It creates a payment customer and SetupIntent without requiring a user_id. Args: user_data: User data (email, full_name, etc.) - NO user_id required plan_id: Subscription plan ID payment_method_id: Payment method ID from frontend billing_interval: Billing interval (monthly/yearly) coupon_code: Optional coupon code Returns: Dictionary with payment setup results including SetupIntent if required Raises: Exception: If payment setup fails """ try: logger.info("Starting registration payment setup (pre-user-creation)", email=user_data.get('email'), plan_id=plan_id) # Step 1: Create payment customer (without user_id) logger.info("Creating payment customer for registration", email=user_data.get('email')) # Create customer without user_id metadata email = user_data.get('email') name = user_data.get('full_name') metadata = { 'registration_flow': 'pre_user_creation', 'timestamp': datetime.now(timezone.utc).isoformat() } customer = await self.payment_service.create_customer(email, name, metadata) logger.info("Payment customer created for registration", customer_id=customer.id, email=user_data.get('email')) # Step 2: Handle coupon logic (if provided) trial_period_days = 0 coupon_discount = None if coupon_code: logger.info("Validating and redeeming coupon code for registration", coupon_code=coupon_code, email=user_data.get('email')) coupon_service = CouponService(self.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: coupon_discount = discount_applied trial_period_days = discount_applied.get("total_trial_days", 0) logger.info("Coupon redeemed successfully for registration", coupon_code=coupon_code, trial_period_days=trial_period_days) else: logger.warning("Failed to redeem coupon for registration, continuing without it", coupon_code=coupon_code, error=error) # Step 3: Create subscription/SetupIntent logger.info("Creating subscription/SetupIntent for registration", customer_id=customer.id, plan_id=plan_id, payment_method_id=payment_method_id) # Get the Stripe price ID for this plan price_id = self.payment_service._get_stripe_price_id(plan_id, billing_interval) subscription_result = await self.payment_service.create_subscription_with_verified_payment( customer.id, price_id, payment_method_id, trial_period_days if trial_period_days > 0 else None, billing_interval ) # Check if result requires 3DS authentication (SetupIntent confirmation) if isinstance(subscription_result, dict) and subscription_result.get('requires_action'): logger.info("Registration payment setup requires SetupIntent confirmation", customer_id=customer.id, action_type=subscription_result.get('action_type'), setup_intent_id=subscription_result.get('setup_intent_id'), subscription_id=subscription_result.get('subscription_id')) # Return the SetupIntent data for frontend to handle 3DS # Note: subscription_id is included because for trial subscriptions, # the subscription is already created in 'trialing' status even though # the SetupIntent requires 3DS verification for future payments return { "requires_action": True, "action_type": subscription_result.get('action_type') or 'use_stripe_sdk', "client_secret": subscription_result.get('client_secret'), "setup_intent_id": subscription_result.get('setup_intent_id'), "subscription_id": subscription_result.get('subscription_id'), "customer_id": customer.id, "payment_customer_id": customer.id, "plan_id": plan_id, "payment_method_id": payment_method_id, "trial_period_days": trial_period_days, "billing_interval": billing_interval, "coupon_applied": coupon_code is not None, "email": user_data.get('email'), "full_name": user_data.get('full_name'), "message": subscription_result.get('message') or "Payment verification required before account creation" } else: # No 3DS required - subscription created successfully logger.info("Registration payment setup completed without 3DS", customer_id=customer.id, subscription_id=subscription_result.get('subscription_id')) return { "requires_action": False, "subscription_id": subscription_result.get('subscription_id'), "customer_id": customer.id, "payment_customer_id": customer.id, "plan_id": plan_id, "payment_method_id": payment_method_id, "trial_period_days": trial_period_days, "billing_interval": billing_interval, "coupon_applied": coupon_code is not None, "email": user_data.get('email'), "full_name": user_data.get('full_name'), "message": "Payment setup completed successfully" } except Exception as e: logger.error("Registration payment setup failed", email=user_data.get('email'), error=str(e), exc_info=True) raise async def verify_setup_intent_for_registration( self, setup_intent_id: str ) -> Dict[str, Any]: """ Verify SetupIntent status for registration completion This method checks if a SetupIntent has been successfully confirmed (either automatically or via 3DS authentication) before proceeding with user creation. Args: setup_intent_id: SetupIntent ID to verify Returns: Dictionary with SetupIntent verification result Raises: Exception: If verification fails """ try: logger.info("Verifying SetupIntent for registration completion", setup_intent_id=setup_intent_id) # Use payment service to verify SetupIntent verification_result = await self.payment_service.verify_setup_intent(setup_intent_id) logger.info("SetupIntent verification result for registration", setup_intent_id=setup_intent_id, status=verification_result.get('status')) return verification_result except Exception as e: logger.error("SetupIntent verification failed for registration", setup_intent_id=setup_intent_id, error=str(e), exc_info=True) raise async def validate_plan_upgrade( self, tenant_id: str, new_plan: str ) -> Dict[str, Any]: """ Validate if a tenant can upgrade to a new plan Args: tenant_id: Tenant ID new_plan: New plan to validate upgrade to Returns: Dictionary with validation result """ try: logger.info("Validating plan upgrade", tenant_id=tenant_id, new_plan=new_plan) # Delegate to subscription service for validation can_upgrade = await self.subscription_service.validate_subscription_change( tenant_id, new_plan ) result = { "can_upgrade": can_upgrade, "tenant_id": tenant_id, "current_plan": None, # Would need to fetch current plan if needed "new_plan": new_plan } if not can_upgrade: result["reason"] = "Subscription change not allowed based on current status" logger.info("Plan upgrade validation completed", tenant_id=tenant_id, can_upgrade=can_upgrade) return result except Exception as e: logger.error("Plan upgrade validation failed", tenant_id=tenant_id, new_plan=new_plan, error=str(e), exc_info=True) raise DatabaseError(f"Failed to validate plan upgrade: {str(e)}") async def get_subscriptions_by_customer_id(self, customer_id: str) -> List[Subscription]: """ Get all subscriptions for a given customer ID Args: customer_id: Stripe customer ID Returns: List of Subscription objects """ try: return await self.subscription_service.get_subscriptions_by_customer_id(customer_id) except Exception as e: logger.error("Failed to get subscriptions by customer ID", customer_id=customer_id, error=str(e), exc_info=True) raise DatabaseError(f"Failed to get subscriptions: {str(e)}") async def update_subscription_with_verified_payment( self, subscription_id: str, customer_id: str, payment_method_id: str, trial_period_days: Optional[int] = None ) -> Dict[str, Any]: """ Update an existing subscription with a verified payment method This is used when we already have a trial subscription and just need to attach the verified payment method to it. Args: subscription_id: Stripe subscription ID customer_id: Stripe customer ID payment_method_id: Verified payment method ID trial_period_days: Optional trial period (for validation) Returns: Dictionary with updated subscription details """ try: logger.info("Updating existing subscription with verified payment method", subscription_id=subscription_id, customer_id=customer_id, payment_method_id=payment_method_id) # First, verify the subscription exists and get its current status existing_subscription = await self.subscription_service.get_subscription_by_provider_id(subscription_id) if not existing_subscription: raise SubscriptionNotFound(f"Subscription {subscription_id} not found") # Update the subscription in Stripe with the verified payment method stripe_subscription = await self.payment_service.update_subscription_payment_method( subscription_id, payment_method_id ) # Update our local subscription record await self.subscription_service.update_subscription_status( existing_subscription.tenant_id, stripe_subscription.status, { 'current_period_start': datetime.fromtimestamp(stripe_subscription.current_period_start), 'current_period_end': datetime.fromtimestamp(stripe_subscription.current_period_end) } ) # Create a mock subscription object-like dict for compatibility class SubscriptionResult: def __init__(self, data: Dict[str, Any]): self.id = data.get('subscription_id') self.status = data.get('status') self.current_period_start = data.get('current_period_start') self.current_period_end = data.get('current_period_end') self.customer = data.get('customer_id') return { 'subscription': SubscriptionResult({ 'subscription_id': stripe_subscription.id, 'status': stripe_subscription.status, 'current_period_start': stripe_subscription.current_period_start, 'current_period_end': stripe_subscription.current_period_end, 'customer_id': customer_id }), 'verification': { 'verified': True, 'customer_id': customer_id, 'payment_method_id': payment_method_id } } except Exception as e: logger.error("Failed to update subscription with verified payment", subscription_id=subscription_id, customer_id=customer_id, error=str(e), exc_info=True) raise SubscriptionUpdateFailed(f"Failed to update subscription: {str(e)}")