Files
bakery-ia/services/tenant/app/services/subscription_orchestration_service.py
2026-01-13 22:22:38 +01:00

1168 lines
46 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
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'))
customer = await self.payment_service.create_customer(user_data)
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)
stripe_subscription = await self.payment_service.create_payment_subscription(
customer.id,
plan_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,
stripe_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 = {
'stripe_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'))
customer = await self.payment_service.create_customer(user_data)
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
logger.info("Creating subscription in payment provider",
customer_id=customer.id,
plan_id=plan_id,
trial_period_days=trial_period_days)
stripe_subscription = await self.payment_service.create_payment_subscription(
customer.id,
plan_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 WITHOUT tenant_id
logger.info("Creating tenant-independent subscription record",
user_id=user_data.get('user_id'),
stripe_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 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",
stripe_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",
stripe_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",
stripe_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")
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_stripe_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_stripe_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_stripe_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_stripe_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_stripe_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_stripe_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_stripe_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_stripe_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_stripe_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 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