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