Refactor subcription layer
This commit is contained in:
424
services/tenant/app/services/subscription_service.py
Normal file
424
services/tenant/app/services/subscription_service.py
Normal file
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
Subscription Service for managing subscription lifecycle operations
|
||||
This service orchestrates business logic and integrates with payment providers
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.tenants import Subscription, Tenant
|
||||
from app.repositories.subscription_repository import SubscriptionRepository
|
||||
from app.services.payment_service import PaymentService
|
||||
from shared.clients.stripe_client import StripeProvider
|
||||
from app.core.config import settings
|
||||
from shared.database.exceptions import DatabaseError, ValidationError
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
"""Service for managing subscription lifecycle operations"""
|
||||
|
||||
def __init__(self, db_session: AsyncSession):
|
||||
self.db_session = db_session
|
||||
self.subscription_repo = SubscriptionRepository(Subscription, db_session)
|
||||
self.payment_service = PaymentService()
|
||||
|
||||
async def cancel_subscription(
|
||||
self,
|
||||
tenant_id: str,
|
||||
reason: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Cancel a subscription with proper business logic and payment provider integration
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID to cancel subscription for
|
||||
reason: Optional cancellation reason
|
||||
|
||||
Returns:
|
||||
Dictionary with cancellation details
|
||||
"""
|
||||
try:
|
||||
tenant_uuid = UUID(tenant_id)
|
||||
|
||||
# Get subscription from repository
|
||||
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
|
||||
|
||||
if not subscription:
|
||||
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
|
||||
|
||||
if subscription.status in ['pending_cancellation', 'inactive']:
|
||||
raise ValidationError(f"Subscription is already {subscription.status}")
|
||||
|
||||
# Calculate cancellation effective date (end of billing period)
|
||||
cancellation_effective_date = subscription.next_billing_date or (
|
||||
datetime.now(timezone.utc) + timedelta(days=30)
|
||||
)
|
||||
|
||||
# Update subscription status in database
|
||||
update_data = {
|
||||
'status': 'pending_cancellation',
|
||||
'cancelled_at': datetime.now(timezone.utc),
|
||||
'cancellation_effective_date': cancellation_effective_date
|
||||
}
|
||||
|
||||
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
|
||||
|
||||
# Invalidate subscription cache
|
||||
try:
|
||||
from app.services.subscription_cache import get_subscription_cache_service
|
||||
import shared.redis_utils
|
||||
|
||||
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
|
||||
cache_service = get_subscription_cache_service(redis_client)
|
||||
await cache_service.invalidate_subscription_cache(str(tenant_id))
|
||||
|
||||
logger.info(
|
||||
"Subscription cache invalidated after cancellation",
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
except Exception as cache_error:
|
||||
logger.error(
|
||||
"Failed to invalidate subscription cache after cancellation",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(cache_error)
|
||||
)
|
||||
|
||||
days_remaining = (cancellation_effective_date - datetime.now(timezone.utc)).days
|
||||
|
||||
logger.info(
|
||||
"subscription_cancelled",
|
||||
tenant_id=str(tenant_id),
|
||||
effective_date=cancellation_effective_date.isoformat(),
|
||||
reason=reason[:200] if reason else None
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Subscription cancelled successfully. You will have read-only access until the end of your billing period.",
|
||||
"status": "pending_cancellation",
|
||||
"cancellation_effective_date": cancellation_effective_date.isoformat(),
|
||||
"days_remaining": days_remaining,
|
||||
"read_only_mode_starts": cancellation_effective_date.isoformat()
|
||||
}
|
||||
|
||||
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_failed", error=str(e), tenant_id=tenant_id)
|
||||
raise DatabaseError(f"Failed to cancel subscription: {str(e)}")
|
||||
|
||||
async def reactivate_subscription(
|
||||
self,
|
||||
tenant_id: str,
|
||||
plan: str = "starter"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Reactivate a cancelled or inactive subscription
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID to reactivate subscription for
|
||||
plan: Plan to reactivate with
|
||||
|
||||
Returns:
|
||||
Dictionary with reactivation details
|
||||
"""
|
||||
try:
|
||||
tenant_uuid = UUID(tenant_id)
|
||||
|
||||
# Get subscription from repository
|
||||
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
|
||||
|
||||
if not subscription:
|
||||
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
|
||||
|
||||
if subscription.status not in ['pending_cancellation', 'inactive']:
|
||||
raise ValidationError(f"Cannot reactivate subscription with status: {subscription.status}")
|
||||
|
||||
# Update subscription status and plan
|
||||
update_data = {
|
||||
'status': 'active',
|
||||
'plan': plan,
|
||||
'cancelled_at': None,
|
||||
'cancellation_effective_date': None
|
||||
}
|
||||
|
||||
if subscription.status == 'inactive':
|
||||
update_data['next_billing_date'] = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
|
||||
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
|
||||
|
||||
# Invalidate subscription cache
|
||||
try:
|
||||
from app.services.subscription_cache import get_subscription_cache_service
|
||||
import shared.redis_utils
|
||||
|
||||
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
|
||||
cache_service = get_subscription_cache_service(redis_client)
|
||||
await cache_service.invalidate_subscription_cache(str(tenant_id))
|
||||
|
||||
logger.info(
|
||||
"Subscription cache invalidated after reactivation",
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
except Exception as cache_error:
|
||||
logger.error(
|
||||
"Failed to invalidate subscription cache after reactivation",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(cache_error)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"subscription_reactivated",
|
||||
tenant_id=str(tenant_id),
|
||||
new_plan=plan
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Subscription reactivated successfully",
|
||||
"status": "active",
|
||||
"plan": plan,
|
||||
"next_billing_date": updated_subscription.next_billing_date.isoformat() if updated_subscription.next_billing_date else None
|
||||
}
|
||||
|
||||
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_failed", error=str(e), tenant_id=tenant_id)
|
||||
raise DatabaseError(f"Failed to reactivate subscription: {str(e)}")
|
||||
|
||||
async def get_subscription_status(
|
||||
self,
|
||||
tenant_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current subscription status including read-only mode info
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID to get status for
|
||||
|
||||
Returns:
|
||||
Dictionary with subscription status details
|
||||
"""
|
||||
try:
|
||||
tenant_uuid = UUID(tenant_id)
|
||||
|
||||
# Get subscription from repository
|
||||
subscription = await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
|
||||
|
||||
if not subscription:
|
||||
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
|
||||
|
||||
is_read_only = subscription.status in ['pending_cancellation', 'inactive']
|
||||
days_until_inactive = None
|
||||
|
||||
if subscription.status == 'pending_cancellation' and subscription.cancellation_effective_date:
|
||||
days_until_inactive = (subscription.cancellation_effective_date - datetime.now(timezone.utc)).days
|
||||
|
||||
return {
|
||||
"tenant_id": str(tenant_id),
|
||||
"status": subscription.status,
|
||||
"plan": subscription.plan,
|
||||
"is_read_only": is_read_only,
|
||||
"cancellation_effective_date": subscription.cancellation_effective_date.isoformat() if subscription.cancellation_effective_date else None,
|
||||
"days_until_inactive": days_until_inactive
|
||||
}
|
||||
|
||||
except ValidationError as ve:
|
||||
logger.error("get_subscription_status_validation_failed",
|
||||
error=str(ve), tenant_id=tenant_id)
|
||||
raise ve
|
||||
except Exception as e:
|
||||
logger.error("get_subscription_status_failed", error=str(e), tenant_id=tenant_id)
|
||||
raise DatabaseError(f"Failed to get subscription status: {str(e)}")
|
||||
|
||||
async def get_tenant_invoices(
|
||||
self,
|
||||
tenant_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get invoice history for a tenant from payment provider
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID to get invoices for
|
||||
|
||||
Returns:
|
||||
List of invoice dictionaries
|
||||
"""
|
||||
try:
|
||||
tenant_uuid = UUID(tenant_id)
|
||||
|
||||
# Verify tenant exists
|
||||
query = select(Tenant).where(Tenant.id == tenant_uuid)
|
||||
result = await self.db_session.execute(query)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
raise ValidationError(f"Tenant not found: {tenant_id}")
|
||||
|
||||
# Check if tenant has a payment provider customer ID
|
||||
if not tenant.stripe_customer_id:
|
||||
logger.info("no_stripe_customer_id", tenant_id=tenant_id)
|
||||
return []
|
||||
|
||||
# Initialize payment provider (Stripe in this case)
|
||||
stripe_provider = StripeProvider(
|
||||
api_key=settings.STRIPE_SECRET_KEY,
|
||||
webhook_secret=settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
|
||||
# Fetch invoices from payment provider
|
||||
stripe_invoices = await stripe_provider.get_invoices(tenant.stripe_customer_id)
|
||||
|
||||
# Transform to response format
|
||||
invoices = []
|
||||
for invoice in stripe_invoices:
|
||||
invoices.append({
|
||||
"id": invoice.id,
|
||||
"date": invoice.created_at.strftime('%Y-%m-%d'),
|
||||
"amount": invoice.amount,
|
||||
"currency": invoice.currency.upper(),
|
||||
"status": invoice.status,
|
||||
"description": invoice.description,
|
||||
"invoice_pdf": invoice.invoice_pdf,
|
||||
"hosted_invoice_url": invoice.hosted_invoice_url
|
||||
})
|
||||
|
||||
logger.info("invoices_retrieved", tenant_id=tenant_id, count=len(invoices))
|
||||
return invoices
|
||||
|
||||
except ValidationError as ve:
|
||||
logger.error("get_invoices_validation_failed",
|
||||
error=str(ve), tenant_id=tenant_id)
|
||||
raise ve
|
||||
except Exception as e:
|
||||
logger.error("get_invoices_failed", error=str(e), tenant_id=tenant_id)
|
||||
raise DatabaseError(f"Failed to retrieve invoices: {str(e)}")
|
||||
|
||||
async def create_subscription(
|
||||
self,
|
||||
tenant_id: str,
|
||||
plan: str,
|
||||
payment_method_id: str,
|
||||
trial_period_days: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new subscription for a tenant
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
plan: Subscription plan
|
||||
payment_method_id: Payment method ID from payment provider
|
||||
trial_period_days: Optional trial period in days
|
||||
|
||||
Returns:
|
||||
Dictionary with subscription creation details
|
||||
"""
|
||||
try:
|
||||
tenant_uuid = UUID(tenant_id)
|
||||
|
||||
# Verify tenant exists
|
||||
query = select(Tenant).where(Tenant.id == tenant_uuid)
|
||||
result = await self.db_session.execute(query)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
raise ValidationError(f"Tenant not found: {tenant_id}")
|
||||
|
||||
if not tenant.stripe_customer_id:
|
||||
raise ValidationError(f"Tenant {tenant_id} does not have a payment provider customer ID")
|
||||
|
||||
# Create subscription through payment provider
|
||||
subscription_result = await self.payment_service.create_subscription(
|
||||
tenant.stripe_customer_id,
|
||||
plan,
|
||||
payment_method_id,
|
||||
trial_period_days
|
||||
)
|
||||
|
||||
# Create local subscription record
|
||||
subscription_data = {
|
||||
'tenant_id': str(tenant_id),
|
||||
'stripe_subscription_id': subscription_result.id,
|
||||
'plan': plan,
|
||||
'status': subscription_result.status,
|
||||
'current_period_start': subscription_result.current_period_start,
|
||||
'current_period_end': subscription_result.current_period_end,
|
||||
'created_at': datetime.now(timezone.utc),
|
||||
'next_billing_date': subscription_result.current_period_end,
|
||||
'trial_period_days': trial_period_days
|
||||
}
|
||||
|
||||
created_subscription = await self.subscription_repo.create(subscription_data)
|
||||
|
||||
logger.info("subscription_created",
|
||||
tenant_id=tenant_id,
|
||||
subscription_id=subscription_result.id,
|
||||
plan=plan)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"subscription_id": subscription_result.id,
|
||||
"status": subscription_result.status,
|
||||
"plan": plan,
|
||||
"current_period_end": subscription_result.current_period_end.isoformat()
|
||||
}
|
||||
|
||||
except ValidationError as ve:
|
||||
logger.error("create_subscription_validation_failed",
|
||||
error=str(ve), tenant_id=tenant_id)
|
||||
raise ve
|
||||
except Exception as e:
|
||||
logger.error("create_subscription_failed", error=str(e), tenant_id=tenant_id)
|
||||
raise DatabaseError(f"Failed to create subscription: {str(e)}")
|
||||
|
||||
async def get_subscription_by_tenant_id(
|
||||
self,
|
||||
tenant_id: str
|
||||
) -> Optional[Subscription]:
|
||||
"""
|
||||
Get subscription by tenant ID
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
|
||||
Returns:
|
||||
Subscription object or None
|
||||
"""
|
||||
try:
|
||||
tenant_uuid = UUID(tenant_id)
|
||||
return await self.subscription_repo.get_by_tenant_id(str(tenant_uuid))
|
||||
except Exception as e:
|
||||
logger.error("get_subscription_by_tenant_id_failed",
|
||||
error=str(e), tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
async def get_subscription_by_stripe_id(
|
||||
self,
|
||||
stripe_subscription_id: str
|
||||
) -> Optional[Subscription]:
|
||||
"""
|
||||
Get subscription by Stripe subscription ID
|
||||
|
||||
Args:
|
||||
stripe_subscription_id: Stripe subscription ID
|
||||
|
||||
Returns:
|
||||
Subscription object or None
|
||||
"""
|
||||
try:
|
||||
return await self.subscription_repo.get_by_stripe_id(stripe_subscription_id)
|
||||
except Exception as e:
|
||||
logger.error("get_subscription_by_stripe_id_failed",
|
||||
error=str(e), stripe_subscription_id=stripe_subscription_id)
|
||||
return None
|
||||
Reference in New Issue
Block a user