Refactor subcription layer

This commit is contained in:
Urtzi Alfaro
2026-01-11 21:40:04 +01:00
parent 54163843ec
commit 55bb1c6451
7 changed files with 1369 additions and 300 deletions

View 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