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

1954 lines
79 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
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)}")