1168 lines
46 KiB
Python
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
|