Files
bakery-ia/services/tenant/app/services/subscription_orchestration_service.py
2026-01-16 15:19:34 +01:00

2114 lines
85 KiB
Python

"""
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, List
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.models.tenants import Subscription
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 = 0
) -> 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 get_invoices(self, tenant_id: str) -> Dict[str, Any]:
"""
Get invoice history for a tenant's subscription
This is an orchestration method that coordinates between:
1. SubscriptionService (to get subscription data)
2. PaymentService (to get invoices from provider)
Args:
tenant_id: Tenant ID
Returns:
Dictionary with invoices data
"""
try:
# Get subscription from database
subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id)
if not subscription:
logger.warning("get_invoices_no_subscription",
tenant_id=tenant_id)
return {"invoices": []}
# Check if subscription has a customer ID
if not subscription.customer_id:
logger.warning("get_invoices_no_customer_id",
tenant_id=tenant_id)
return {"invoices": []}
# Get invoices from payment provider
invoices_result = await self.payment_service.stripe_client.get_invoices(subscription.customer_id)
logger.info("invoices_retrieved",
tenant_id=tenant_id,
customer_id=subscription.customer_id,
invoice_count=len(invoices_result.get("invoices", [])))
return invoices_result
except Exception as e:
logger.error("get_invoices_failed",
error=str(e),
tenant_id=tenant_id,
exc_info=True)
return {"invoices": []}
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.
NEW ARCHITECTURE: Only creates customer + SetupIntent here.
Subscription is created AFTER SetupIntent verification completes.
Flow:
1. Create Stripe customer
2. Handle coupon (get trial days)
3. Create SetupIntent for payment verification
4. Return SetupIntent to frontend for 3DS handling
5. (Later) complete_registration_subscription() creates subscription
Args:
user_data: User data (email, full_name, etc.)
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 SetupIntent data for frontend
Raises:
Exception: If payment setup fails
"""
try:
logger.info("Starting registration payment setup",
email=user_data.get('email'),
plan_id=plan_id)
# Step 1: Create payment customer
email = user_data.get('email')
name = user_data.get('full_name')
metadata = {
'registration_flow': 'setup_intent_first',
'plan_id': plan_id,
'billing_interval': billing_interval,
'timestamp': datetime.now(timezone.utc).isoformat()
}
customer = await self.payment_service.create_customer(email, name, metadata)
logger.info("Customer created for registration",
customer_id=customer.id,
email=email)
# Step 2: Handle coupon logic (if provided)
trial_period_days = 0
if coupon_code:
logger.info("Validating coupon for registration",
coupon_code=coupon_code,
email=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:
trial_period_days = discount_applied.get("total_trial_days", 0)
logger.info("Coupon validated for registration",
coupon_code=coupon_code,
trial_period_days=trial_period_days)
else:
logger.warning("Failed to validate coupon, continuing without it",
coupon_code=coupon_code,
error=error)
# Step 3: Create SetupIntent (NO subscription yet!)
logger.info("Creating SetupIntent for registration",
customer_id=customer.id,
payment_method_id=payment_method_id)
setup_result = await self.payment_service.create_setup_intent_for_registration(
customer.id,
payment_method_id,
{
'purpose': 'registration',
'plan_id': plan_id,
'billing_interval': billing_interval,
'trial_period_days': str(trial_period_days),
'coupon_code': coupon_code or ''
}
)
logger.info("SetupIntent created for registration",
setup_intent_id=setup_result.get('setup_intent_id'),
requires_action=setup_result.get('requires_action'),
status=setup_result.get('status'))
# Return result for frontend
# Frontend will call complete_registration_subscription() after 3DS
return {
"requires_action": setup_result.get('requires_action', True),
"action_type": "use_stripe_sdk",
"client_secret": setup_result.get('client_secret'),
"setup_intent_id": setup_result.get('setup_intent_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_code": coupon_code,
"email": email,
"full_name": name,
"message": "Payment verification required" if setup_result.get('requires_action') else "Payment verified"
}
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.
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",
setup_intent_id=setup_intent_id)
verification_result = await self.payment_service.verify_setup_intent(setup_intent_id)
logger.info("SetupIntent verification result",
setup_intent_id=setup_intent_id,
status=verification_result.get('status'),
verified=verification_result.get('verified'))
return verification_result
except Exception as e:
logger.error("SetupIntent verification failed",
setup_intent_id=setup_intent_id,
error=str(e),
exc_info=True)
raise
async def complete_registration_subscription(
self,
setup_intent_id: str,
customer_id: str,
plan_id: str,
payment_method_id: str,
billing_interval: str = "monthly",
trial_period_days: int = 0,
user_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Create subscription AFTER SetupIntent verification succeeds.
NEW ARCHITECTURE: This is called AFTER 3DS verification completes.
The subscription is created here, not during payment setup.
Args:
setup_intent_id: Verified SetupIntent ID
customer_id: Stripe customer ID
plan_id: Subscription plan ID
payment_method_id: Verified payment method ID
billing_interval: Billing interval (monthly/yearly)
trial_period_days: Trial period in days (from coupon)
user_id: Optional user ID if user already created
Returns:
Dictionary with subscription details
Raises:
Exception: If subscription creation fails
"""
try:
logger.info("Creating subscription after verification",
setup_intent_id=setup_intent_id,
customer_id=customer_id,
plan_id=plan_id,
trial_period_days=trial_period_days)
# Verify SetupIntent is successful
verification = await self.payment_service.verify_setup_intent(setup_intent_id)
if not verification.get('verified'):
raise ValidationError(
f"SetupIntent not verified. Status: {verification.get('status')}"
)
# Get actual customer_id and payment_method_id from verification
actual_customer_id = verification.get('customer_id') or customer_id
actual_payment_method_id = verification.get('payment_method_id') or payment_method_id
# Get price ID for the plan
price_id = self.payment_service._get_stripe_price_id(plan_id, billing_interval)
# Create subscription in Stripe
subscription_result = await self.payment_service.create_subscription_after_verification(
actual_customer_id,
price_id,
actual_payment_method_id,
trial_period_days if trial_period_days > 0 else None,
{
'plan_id': plan_id,
'billing_interval': billing_interval,
'created_via': 'registration_flow',
'setup_intent_id': setup_intent_id
}
)
logger.info("Subscription created after verification",
subscription_id=subscription_result.get('subscription_id'),
status=subscription_result.get('status'),
trial_period_days=trial_period_days)
# Create local subscription record (without tenant_id for now)
subscription_record = await self.subscription_service.create_tenant_independent_subscription_record(
subscription_result['subscription_id'],
actual_customer_id,
plan_id,
subscription_result['status'],
trial_period_days,
billing_interval,
user_id
)
logger.info("Subscription record created",
subscription_id=subscription_result['subscription_id'],
record_id=str(subscription_record.id) if subscription_record else None)
return {
'subscription_id': subscription_result['subscription_id'],
'customer_id': actual_customer_id,
'payment_customer_id': actual_customer_id,
'payment_method_id': actual_payment_method_id,
'status': subscription_result['status'],
'plan_id': plan_id,
'trial_period_days': trial_period_days,
'current_period_end': subscription_result.get('current_period_end'),
'message': 'Subscription created successfully'
}
except Exception as e:
logger.error("Subscription creation after verification failed",
setup_intent_id=setup_intent_id,
customer_id=customer_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 trial subscription with a verified payment method
This is used when we already have a trial subscription (created during registration)
and just need to attach the verified payment method to it after 3DS verification.
For trial subscriptions, the payment method should be:
1. Attached to the customer (for trial period)
2. Set as default payment method on the subscription (for future billing)
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 trial subscription with verified payment method",
subscription_id=subscription_id,
customer_id=customer_id,
payment_method_id=payment_method_id,
trial_period_days=trial_period_days)
# 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")
# For trial subscriptions, we need to:
# 1. Ensure payment method is attached to customer
# 2. Set it as default payment method on subscription
# Step 1: Attach payment method to customer (if not already attached)
try:
await self.payment_service.attach_payment_method_to_customer(
customer_id,
payment_method_id
)
logger.info("Payment method attached to customer for trial subscription",
customer_id=customer_id,
payment_method_id=payment_method_id)
except Exception as e:
logger.warning("Payment method may already be attached to customer",
customer_id=customer_id,
payment_method_id=payment_method_id,
error=str(e))
# Step 2: Set payment method as default on subscription
stripe_subscription = await self.payment_service.update_subscription_payment_method(
subscription_id,
payment_method_id
)
# Step 3: Also set as default payment method on customer for future invoices
await self.payment_service.set_customer_default_payment_method(
customer_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),
'payment_method_verified': True,
'payment_method_id': payment_method_id
}
)
# 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,
'trial_period_days': trial_period_days
},
'trial_preserved': True,
'payment_method_updated': True
}
except Exception as e:
logger.error("Failed to update trial subscription with verified payment",
subscription_id=subscription_id,
customer_id=customer_id,
error=str(e),
exc_info=True)
raise SubscriptionUpdateFailed(f"Failed to update trial subscription: {str(e)}")