Add subcription feature

This commit is contained in:
Urtzi Alfaro
2026-01-13 22:22:38 +01:00
parent b931a5c45e
commit 6ddf608d37
61 changed files with 7915 additions and 1238 deletions

View File

@@ -4,8 +4,16 @@ Business logic services for tenant operations
"""
from .tenant_service import TenantService, EnhancedTenantService
from .subscription_service import SubscriptionService
from .payment_service import PaymentService
from .coupon_service import CouponService
from .subscription_orchestration_service import SubscriptionOrchestrationService
__all__ = [
"TenantService",
"EnhancedTenantService"
"EnhancedTenantService",
"SubscriptionService",
"PaymentService",
"CouponService",
"SubscriptionOrchestrationService"
]

View File

@@ -0,0 +1,108 @@
"""
Coupon Service - Coupon Operations
This service handles ONLY coupon validation and redemption
NO payment provider interactions, NO subscription logic
"""
import structlog
from typing import Dict, Any, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.coupon_repository import CouponRepository
logger = structlog.get_logger()
class CouponService:
"""Service for handling coupon validation and redemption"""
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
self.coupon_repo = CouponRepository(db_session)
async def validate_coupon_code(
self,
coupon_code: str,
tenant_id: str
) -> Dict[str, Any]:
"""
Validate a coupon code for a tenant
Args:
coupon_code: Coupon code to validate
tenant_id: Tenant ID
Returns:
Dictionary with validation results
"""
try:
validation = await self.coupon_repo.validate_coupon(coupon_code, tenant_id)
return {
"valid": validation.valid,
"error_message": validation.error_message,
"discount_preview": validation.discount_preview,
"coupon": {
"code": validation.coupon.code,
"discount_type": validation.coupon.discount_type.value,
"discount_value": validation.coupon.discount_value
} if validation.coupon else None
}
except Exception as e:
logger.error("Failed to validate coupon", error=str(e), coupon_code=coupon_code)
return {
"valid": False,
"error_message": "Error al validar el cupón",
"discount_preview": None,
"coupon": None
}
async def redeem_coupon(
self,
coupon_code: str,
tenant_id: str,
base_trial_days: int = 14
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
"""
Redeem a coupon for a tenant
Args:
coupon_code: Coupon code to redeem
tenant_id: Tenant ID
base_trial_days: Base trial days without coupon
Returns:
Tuple of (success, discount_applied, error_message)
"""
try:
success, redemption, error = await self.coupon_repo.redeem_coupon(
coupon_code,
tenant_id,
base_trial_days
)
if success and redemption:
return True, redemption.discount_applied, None
else:
return False, None, error
except Exception as e:
logger.error("Failed to redeem coupon", error=str(e), coupon_code=coupon_code)
return False, None, f"Error al aplicar el cupón: {str(e)}"
async def get_coupon_by_code(self, coupon_code: str) -> Optional[Any]:
"""
Get coupon details by code
Args:
coupon_code: Coupon code to retrieve
Returns:
Coupon object or None
"""
try:
return await self.coupon_repo.get_coupon_by_code(coupon_code)
except Exception as e:
logger.error("Failed to get coupon by code", error=str(e), coupon_code=coupon_code)
return None

View File

@@ -1,41 +1,30 @@
"""
Payment Service for handling subscription payments
This service abstracts payment provider interactions and makes the system payment-agnostic
Payment Service - Payment Provider Gateway
This service handles ONLY payment provider interactions (Stripe, etc.)
NO business logic, NO database operations, NO orchestration
"""
import structlog
from typing import Dict, Any, Optional
import uuid
from typing import Dict, Any, Optional, List
from datetime import datetime
from sqlalchemy.orm import Session
from app.core.config import settings
from shared.clients.payment_client import PaymentProvider, PaymentCustomer, Subscription, PaymentMethod
from shared.clients.stripe_client import StripeProvider
from shared.database.base import create_database_manager
from app.repositories.subscription_repository import SubscriptionRepository
from app.repositories.coupon_repository import CouponRepository
from app.models.tenants import Subscription as SubscriptionModel
logger = structlog.get_logger()
class PaymentService:
"""Service for handling payment provider interactions"""
"""Service for handling payment provider interactions ONLY"""
def __init__(self, db_session: Optional[Session] = None):
def __init__(self):
# Initialize payment provider based on configuration
# For now, we'll use Stripe, but this can be swapped for other providers
self.payment_provider: PaymentProvider = StripeProvider(
api_key=settings.STRIPE_SECRET_KEY,
webhook_secret=settings.STRIPE_WEBHOOK_SECRET
)
# Initialize database components
self.database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
self.subscription_repo = SubscriptionRepository(SubscriptionModel, None) # Will be set in methods
self.db_session = db_session # Optional session for coupon operations
async def create_customer(self, user_data: Dict[str, Any]) -> PaymentCustomer:
"""Create a customer in the payment provider system"""
try:
@@ -47,257 +36,408 @@ class PaymentService:
'tenant_id': user_data.get('tenant_id')
}
}
return await self.payment_provider.create_customer(customer_data)
except Exception as e:
logger.error("Failed to create customer in payment provider", error=str(e))
raise e
async def create_subscription(
self,
customer_id: str,
plan_id: str,
payment_method_id: str,
trial_period_days: Optional[int] = None
async def create_payment_subscription(
self,
customer_id: str,
plan_id: str,
payment_method_id: str,
trial_period_days: Optional[int] = None,
billing_interval: str = "monthly"
) -> Subscription:
"""Create a subscription for a customer"""
"""
Create a subscription in the payment provider
Args:
customer_id: Payment provider customer ID
plan_id: Plan identifier
payment_method_id: Payment method ID
trial_period_days: Optional trial period in days
billing_interval: Billing interval (monthly/yearly)
Returns:
Subscription object from payment provider
"""
try:
# Map the plan ID to the actual Stripe price ID
stripe_price_id = self._get_stripe_price_id(plan_id, billing_interval)
return await self.payment_provider.create_subscription(
customer_id,
plan_id,
payment_method_id,
customer_id,
stripe_price_id,
payment_method_id,
trial_period_days
)
except Exception as e:
logger.error("Failed to create subscription in payment provider", error=str(e))
logger.error("Failed to create subscription in payment provider",
error=str(e),
error_type=type(e).__name__,
customer_id=customer_id,
plan_id=plan_id,
billing_interval=billing_interval,
exc_info=True)
raise e
def validate_coupon_code(
self,
coupon_code: str,
tenant_id: str,
db_session: Session
) -> Dict[str, Any]:
"""
Validate a coupon code for a tenant.
Returns validation result with discount preview.
"""
try:
coupon_repo = CouponRepository(db_session)
validation = coupon_repo.validate_coupon(coupon_code, tenant_id)
return {
"valid": validation.valid,
"error_message": validation.error_message,
"discount_preview": validation.discount_preview,
"coupon": {
"code": validation.coupon.code,
"discount_type": validation.coupon.discount_type.value,
"discount_value": validation.coupon.discount_value
} if validation.coupon else None
}
except Exception as e:
logger.error("Failed to validate coupon", error=str(e), coupon_code=coupon_code)
return {
"valid": False,
"error_message": "Error al validar el cupón",
"discount_preview": None,
"coupon": None
}
def redeem_coupon(
self,
coupon_code: str,
tenant_id: str,
db_session: Session,
base_trial_days: int = 14
) -> tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
def _get_stripe_price_id(self, plan_id: str, billing_interval: str) -> str:
"""
Redeem a coupon for a tenant.
Returns (success, discount_applied, error_message)
Get Stripe price ID for a given plan and billing interval
Args:
plan_id: Subscription plan (starter, professional, enterprise)
billing_interval: Billing interval (monthly, yearly)
Returns:
Stripe price ID
Raises:
ValueError: If plan or billing interval is invalid
"""
try:
coupon_repo = CouponRepository(db_session)
success, redemption, error = coupon_repo.redeem_coupon(
coupon_code,
tenant_id,
base_trial_days
plan_id = plan_id.lower()
billing_interval = billing_interval.lower()
price_id = settings.STRIPE_PRICE_ID_MAPPING.get((plan_id, billing_interval))
if not price_id:
valid_combinations = list(settings.STRIPE_PRICE_ID_MAPPING.keys())
raise ValueError(
f"Invalid plan or billing interval: {plan_id}/{billing_interval}. "
f"Valid combinations: {valid_combinations}"
)
if success and redemption:
return True, redemption.discount_applied, None
else:
return False, None, error
return price_id
async def cancel_payment_subscription(self, subscription_id: str) -> Subscription:
"""
Cancel a subscription in the payment provider
Args:
subscription_id: Payment provider subscription ID
Returns:
Updated Subscription object
"""
try:
return await self.payment_provider.cancel_subscription(subscription_id)
except Exception as e:
logger.error("Failed to cancel subscription in payment provider", error=str(e))
raise e
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
"""
Update the payment method for a customer
Args:
customer_id: Payment provider customer ID
payment_method_id: New payment method ID
Returns:
PaymentMethod object
"""
try:
return await self.payment_provider.update_payment_method(customer_id, payment_method_id)
except Exception as e:
logger.error("Failed to update payment method in payment provider", error=str(e))
raise e
async def get_payment_subscription(self, subscription_id: str) -> Subscription:
"""
Get subscription details from the payment provider
Args:
subscription_id: Payment provider subscription ID
Returns:
Subscription object
"""
try:
return await self.payment_provider.get_subscription(subscription_id)
except Exception as e:
logger.error("Failed to get subscription from payment provider", error=str(e))
raise e
async def update_payment_subscription(
self,
subscription_id: str,
new_price_id: str,
proration_behavior: str = "create_prorations",
billing_cycle_anchor: str = "unchanged",
payment_behavior: str = "error_if_incomplete",
immediate_change: bool = False
) -> Subscription:
"""
Update a subscription in the payment provider
Args:
subscription_id: Payment provider subscription ID
new_price_id: New price ID to switch to
proration_behavior: How to handle prorations
billing_cycle_anchor: When to apply changes
payment_behavior: Payment behavior
immediate_change: Whether to apply changes immediately
Returns:
Updated Subscription object
"""
try:
return await self.payment_provider.update_subscription(
subscription_id,
new_price_id,
proration_behavior,
billing_cycle_anchor,
payment_behavior,
immediate_change
)
except Exception as e:
logger.error("Failed to update subscription in payment provider", error=str(e))
raise e
async def calculate_payment_proration(
self,
subscription_id: str,
new_price_id: str,
proration_behavior: str = "create_prorations"
) -> Dict[str, Any]:
"""
Calculate proration amounts for a subscription change
Args:
subscription_id: Payment provider subscription ID
new_price_id: New price ID
proration_behavior: Proration behavior to use
Returns:
Dictionary with proration details
"""
try:
return await self.payment_provider.calculate_proration(
subscription_id,
new_price_id,
proration_behavior
)
except Exception as e:
logger.error("Failed to calculate proration", error=str(e))
raise e
async def change_billing_cycle(
self,
subscription_id: str,
new_billing_cycle: str,
proration_behavior: str = "create_prorations"
) -> Subscription:
"""
Change billing cycle (monthly ↔ yearly) for a subscription
Args:
subscription_id: Payment provider subscription ID
new_billing_cycle: New billing cycle ('monthly' or 'yearly')
proration_behavior: Proration behavior to use
Returns:
Updated Subscription object
"""
try:
return await self.payment_provider.change_billing_cycle(
subscription_id,
new_billing_cycle,
proration_behavior
)
except Exception as e:
logger.error("Failed to change billing cycle", error=str(e))
raise e
async def get_invoices_from_provider(
self,
customer_id: str
) -> List[Dict[str, Any]]:
"""
Get invoice history for a customer from payment provider
Args:
customer_id: Payment provider customer ID
Returns:
List of invoice dictionaries
"""
try:
# Fetch invoices from payment provider
stripe_invoices = await self.payment_provider.get_invoices(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_from_provider",
customer_id=customer_id,
count=len(invoices))
return invoices
except Exception as e:
logger.error("Failed to redeem coupon", error=str(e), coupon_code=coupon_code)
return False, None, f"Error al aplicar el cupón: {str(e)}"
logger.error("Failed to get invoices from payment provider",
error=str(e),
customer_id=customer_id)
raise e
async def verify_webhook_signature(
self,
payload: bytes,
signature: str
) -> Dict[str, Any]:
"""
Verify webhook signature from payment provider
Args:
payload: Raw webhook payload
signature: Webhook signature header
Returns:
Verified event data
Raises:
Exception: If signature verification fails
"""
try:
import stripe
event = stripe.Webhook.construct_event(
payload, signature, settings.STRIPE_WEBHOOK_SECRET
)
logger.info("Webhook signature verified", event_type=event['type'])
return event
except stripe.error.SignatureVerificationError as e:
logger.error("Invalid webhook signature", error=str(e))
raise e
except Exception as e:
logger.error("Failed to verify webhook signature", error=str(e))
raise e
async def process_registration_with_subscription(
self,
user_data: Dict[str, Any],
plan_id: str,
payment_method_id: str,
use_trial: bool = False,
coupon_code: Optional[str] = None,
db_session: Optional[Session] = None
billing_interval: str = "monthly"
) -> Dict[str, Any]:
"""Process user registration with subscription creation"""
"""
Process user registration with subscription creation
This method handles the complete flow:
1. Create payment customer (if not exists)
2. Attach payment method to customer
3. Create subscription with coupon/trial
4. Return subscription details
Args:
user_data: User data including email, name, etc.
plan_id: Subscription plan ID
payment_method_id: Payment method ID from frontend
coupon_code: Optional coupon code for discounts/trials
billing_interval: Billing interval (monthly/yearly)
Returns:
Dictionary with subscription and customer details
"""
try:
# Create customer in payment provider
# Step 1: Create or get payment customer
customer = await self.create_customer(user_data)
# Determine trial period (default 14 days)
trial_period_days = 14 if use_trial else 0
# Apply coupon if provided
coupon_discount = None
if coupon_code and db_session:
# Redeem coupon
success, discount, error = self.redeem_coupon(
coupon_code,
user_data.get('tenant_id'),
db_session,
trial_period_days
)
if success and discount:
coupon_discount = discount
# Update trial period if coupon extends it
if discount.get("type") == "trial_extension":
trial_period_days = discount.get("total_trial_days", trial_period_days)
logger.info(
"Coupon applied successfully",
coupon_code=coupon_code,
extended_trial_days=trial_period_days
)
logger.info("Payment customer created for registration",
customer_id=customer.id,
email=user_data.get('email'))
# Step 2: Attach payment method to customer
if payment_method_id:
try:
payment_method = await self.update_payment_method(customer.id, payment_method_id)
logger.info("Payment method attached to customer",
customer_id=customer.id,
payment_method_id=payment_method.id)
except Exception as e:
logger.warning("Failed to attach payment method, but continuing with subscription",
customer_id=customer.id,
error=str(e))
# Continue without attached payment method - user can add it later
payment_method = None
# Step 3: Determine trial period from coupon
trial_period_days = None
if coupon_code:
# Check if coupon provides a trial period
# In a real implementation, you would validate the coupon here
# For now, we'll assume PILOT2025 provides a trial
if coupon_code.upper() == "PILOT2025":
trial_period_days = 90 # 3 months trial for pilot users
logger.info("Pilot coupon detected - applying 90-day trial",
coupon_code=coupon_code,
customer_id=customer.id)
else:
logger.warning("Failed to apply coupon", error=error, coupon_code=coupon_code)
# Create subscription
subscription = await self.create_subscription(
# Other coupons might provide different trial periods
# This would be configured in your coupon system
trial_period_days = 30 # Default trial for other coupons
# Step 4: Create subscription
subscription = await self.create_payment_subscription(
customer.id,
plan_id,
payment_method_id,
trial_period_days if trial_period_days > 0 else None
payment_method_id if payment_method_id else None,
trial_period_days,
billing_interval
)
# Save subscription to database
async with self.database_manager.get_session() as session:
self.subscription_repo.session = session
subscription_data = {
'id': str(uuid.uuid4()),
'tenant_id': user_data.get('tenant_id'),
'customer_id': customer.id,
'subscription_id': subscription.id,
'plan_id': plan_id,
'status': subscription.status,
'current_period_start': subscription.current_period_start,
'current_period_end': subscription.current_period_end,
'created_at': subscription.created_at,
'trial_period_days': trial_period_days if trial_period_days > 0 else None
}
subscription_record = await self.subscription_repo.create(subscription_data)
result = {
'customer_id': customer.id,
'subscription_id': subscription.id,
'status': subscription.status,
'trial_period_days': trial_period_days
logger.info("Subscription created successfully during registration",
subscription_id=subscription.id,
customer_id=customer.id,
plan_id=plan_id,
status=subscription.status)
# Step 5: Return comprehensive result
return {
"success": True,
"customer": {
"id": customer.id,
"email": customer.email,
"name": customer.name,
"created_at": customer.created_at.isoformat()
},
"subscription": {
"id": subscription.id,
"customer_id": subscription.customer_id,
"plan_id": plan_id,
"status": subscription.status,
"current_period_start": subscription.current_period_start.isoformat(),
"current_period_end": subscription.current_period_end.isoformat(),
"trial_period_days": trial_period_days,
"billing_interval": billing_interval
},
"payment_method": {
"id": payment_method.id if payment_method else None,
"type": payment_method.type if payment_method else None,
"last4": payment_method.last4 if payment_method else None
} if payment_method else None,
"coupon_applied": coupon_code is not None,
"trial_active": trial_period_days is not None and trial_period_days > 0
}
# Include coupon info if applied
if coupon_discount:
result['coupon_applied'] = {
'code': coupon_code,
'discount': coupon_discount
}
return result
except Exception as e:
logger.error("Failed to process registration with subscription", error=str(e))
raise e
async def cancel_subscription(self, subscription_id: str) -> Subscription:
"""Cancel a subscription in the payment provider"""
try:
return await self.payment_provider.cancel_subscription(subscription_id)
except Exception as e:
logger.error("Failed to cancel subscription in payment provider", error=str(e))
raise e
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
"""Update the payment method for a customer"""
try:
return await self.payment_provider.update_payment_method(customer_id, payment_method_id)
except Exception as e:
logger.error("Failed to update payment method in payment provider", error=str(e))
raise e
async def get_invoices(self, customer_id: str) -> list:
"""Get invoices for a customer from the payment provider"""
try:
return await self.payment_provider.get_invoices(customer_id)
except Exception as e:
logger.error("Failed to get invoices from payment provider", error=str(e))
raise e
async def get_subscription(self, subscription_id: str) -> Subscription:
"""Get subscription details from the payment provider"""
try:
return await self.payment_provider.get_subscription(subscription_id)
except Exception as e:
logger.error("Failed to get subscription from payment provider", error=str(e))
raise e
async def sync_subscription_status(self, subscription_id: str, db_session: Session) -> Subscription:
"""
Sync subscription status from payment provider to database
This ensures our local subscription status matches the payment provider
"""
try:
# Get current subscription from payment provider
stripe_subscription = await self.payment_provider.get_subscription(subscription_id)
logger.info("Syncing subscription status",
subscription_id=subscription_id,
stripe_status=stripe_subscription.status)
# Update local database record
self.subscription_repo.db_session = db_session
local_subscription = await self.subscription_repo.get_by_stripe_id(subscription_id)
if local_subscription:
# Update status and dates
local_subscription.status = stripe_subscription.status
local_subscription.current_period_end = stripe_subscription.current_period_end
# Handle status-specific logic
if stripe_subscription.status == 'active':
local_subscription.is_active = True
local_subscription.canceled_at = None
elif stripe_subscription.status == 'canceled':
local_subscription.is_active = False
local_subscription.canceled_at = datetime.utcnow()
elif stripe_subscription.status == 'past_due':
local_subscription.is_active = False
elif stripe_subscription.status == 'unpaid':
local_subscription.is_active = False
await self.subscription_repo.update(local_subscription)
logger.info("Subscription status synced successfully",
subscription_id=subscription_id,
new_status=stripe_subscription.status)
else:
logger.warning("Local subscription not found for Stripe subscription",
subscription_id=subscription_id)
return stripe_subscription
except Exception as e:
logger.error("Failed to sync subscription status",
error=str(e),
subscription_id=subscription_id)
logger.error("Failed to process registration with subscription",
error=str(e),
plan_id=plan_id,
customer_email=user_data.get('email'))
raise e

View File

@@ -520,7 +520,7 @@ class SubscriptionLimitService:
from shared.clients.inventory_client import create_inventory_client
# Use the shared inventory client with proper authentication
inventory_client = create_inventory_client(settings)
inventory_client = create_inventory_client(settings, service_name="tenant")
count = await inventory_client.count_ingredients(tenant_id)
logger.info(
@@ -545,7 +545,7 @@ class SubscriptionLimitService:
from app.core.config import settings
# Use the shared recipes client with proper authentication and resilience
recipes_client = create_recipes_client(settings)
recipes_client = create_recipes_client(settings, service_name="tenant")
count = await recipes_client.count_recipes(tenant_id)
logger.info(
@@ -570,7 +570,7 @@ class SubscriptionLimitService:
from app.core.config import settings
# Use the shared suppliers client with proper authentication and resilience
suppliers_client = create_suppliers_client(settings)
suppliers_client = create_suppliers_client(settings, service_name="tenant")
count = await suppliers_client.count_suppliers(tenant_id)
logger.info(

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
"""
Subscription Service for managing subscription lifecycle operations
This service orchestrates business logic and integrates with payment providers
Subscription Service - State Manager
This service handles ONLY subscription database operations and state management
NO payment provider interactions, NO orchestration, NO coupon logic
"""
import structlog
@@ -12,92 +13,247 @@ 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
from shared.subscription.plans import PlanPricing, QuotaLimits, SubscriptionPlanMetadata
logger = structlog.get_logger()
class SubscriptionService:
"""Service for managing subscription lifecycle operations"""
"""Service for managing subscription state and database operations ONLY"""
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
self.subscription_repo = SubscriptionRepository(Subscription, db_session)
self.payment_service = PaymentService()
async def create_subscription_record(
self,
tenant_id: str,
stripe_subscription_id: str,
stripe_customer_id: str,
plan: str,
status: str,
trial_period_days: Optional[int] = None,
billing_interval: str = "monthly"
) -> Subscription:
"""
Create a local subscription record in the database
Args:
tenant_id: Tenant ID
stripe_subscription_id: Stripe subscription ID
stripe_customer_id: Stripe customer ID
plan: Subscription plan
status: Subscription status
trial_period_days: Optional trial period in days
billing_interval: Billing interval (monthly or yearly)
Returns:
Created Subscription object
"""
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}")
# Create local subscription record
subscription_data = {
'tenant_id': str(tenant_id),
'subscription_id': stripe_subscription_id, # Stripe subscription ID
'customer_id': stripe_customer_id, # Stripe customer ID
'plan_id': plan,
'status': status,
'created_at': datetime.now(timezone.utc),
'trial_period_days': trial_period_days,
'billing_cycle': billing_interval
}
created_subscription = await self.subscription_repo.create(subscription_data)
logger.info("subscription_record_created",
tenant_id=tenant_id,
subscription_id=stripe_subscription_id,
plan=plan)
return created_subscription
except ValidationError as ve:
logger.error("create_subscription_record_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
logger.error("create_subscription_record_failed", error=str(e), tenant_id=tenant_id)
raise DatabaseError(f"Failed to create subscription record: {str(e)}")
async def update_subscription_status(
self,
tenant_id: str,
status: str,
stripe_data: Optional[Dict[str, Any]] = None
) -> Subscription:
"""
Update subscription status in database
Args:
tenant_id: Tenant ID
status: New subscription status
stripe_data: Optional data from Stripe to update
Returns:
Updated Subscription object
"""
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}")
# Prepare update data
update_data = {
'status': status,
'updated_at': datetime.now(timezone.utc)
}
# Include Stripe data if provided
if stripe_data:
if 'current_period_start' in stripe_data:
update_data['current_period_start'] = stripe_data['current_period_start']
if 'current_period_end' in stripe_data:
update_data['current_period_end'] = stripe_data['current_period_end']
# Update status flags based on status value
if status == 'active':
update_data['is_active'] = True
update_data['canceled_at'] = None
elif status in ['canceled', 'past_due', 'unpaid', 'inactive']:
update_data['is_active'] = False
elif status == 'pending_cancellation':
update_data['is_active'] = True # Still active until effective date
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
# Invalidate subscription cache
await self._invalidate_cache(tenant_id)
logger.info("subscription_status_updated",
tenant_id=tenant_id,
old_status=subscription.status,
new_status=status)
return updated_subscription
except ValidationError as ve:
logger.error("update_subscription_status_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
logger.error("update_subscription_status_failed", error=str(e), tenant_id=tenant_id)
raise DatabaseError(f"Failed to update subscription status: {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
async def cancel_subscription(
self,
tenant_id: str,
reason: str = ""
) -> Dict[str, Any]:
"""
Cancel a subscription with proper business logic and payment provider integration
Mark subscription as pending cancellation in database
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
await self._invalidate_cache(tenant_id)
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.",
@@ -106,9 +262,9 @@ class SubscriptionService:
"days_remaining": days_remaining,
"read_only_mode_starts": cancellation_effective_date.isoformat()
}
except ValidationError as ve:
logger.error("subscription_cancellation_validation_failed",
logger.error("subscription_cancellation_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
@@ -122,65 +278,48 @@ class SubscriptionService:
) -> 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,
'plan_id': 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
await self._invalidate_cache(tenant_id)
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",
@@ -188,9 +327,9 @@ class SubscriptionService:
"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",
logger.error("subscription_reactivation_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
@@ -203,28 +342,28 @@ class SubscriptionService:
) -> 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,
@@ -233,192 +372,332 @@ class SubscriptionService:
"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",
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(
async def update_subscription_plan_record(
self,
tenant_id: str,
plan: str,
payment_method_id: str,
trial_period_days: Optional[int] = None
new_plan: str,
new_status: str,
new_period_start: datetime,
new_period_end: datetime,
billing_cycle: str = "monthly",
proration_details: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Create a new subscription for a tenant
Update local subscription plan record in database
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
new_plan: New plan name
new_status: New subscription status
new_period_start: New period start date
new_period_end: New period end date
billing_cycle: Billing cycle for the new plan
proration_details: Proration details from payment provider
Returns:
Dictionary with subscription creation details
Dictionary with update results
"""
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
# Get current subscription
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}")
# Update local subscription record
update_data = {
'plan_id': new_plan,
'status': new_status,
'current_period_start': new_period_start,
'current_period_end': new_period_end,
'updated_at': datetime.now(timezone.utc)
}
created_subscription = await self.subscription_repo.create(subscription_data)
logger.info("subscription_created",
tenant_id=tenant_id,
subscription_id=subscription_result.id,
plan=plan)
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
# Invalidate subscription cache
await self._invalidate_cache(tenant_id)
logger.info(
"subscription_plan_record_updated",
tenant_id=str(tenant_id),
old_plan=subscription.plan,
new_plan=new_plan,
proration_amount=proration_details.get("net_amount", 0) if proration_details else 0
)
return {
"success": True,
"subscription_id": subscription_result.id,
"status": subscription_result.status,
"plan": plan,
"current_period_end": subscription_result.current_period_end.isoformat()
"message": f"Subscription plan record updated to {new_plan}",
"old_plan": subscription.plan,
"new_plan": new_plan,
"proration_details": proration_details,
"new_status": new_status,
"new_period_end": new_period_end.isoformat()
}
except ValidationError as ve:
logger.error("create_subscription_validation_failed",
logger.error("update_subscription_plan_record_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)}")
logger.error("update_subscription_plan_record_failed", error=str(e), tenant_id=tenant_id)
raise DatabaseError(f"Failed to update subscription plan record: {str(e)}")
async def get_subscription_by_tenant_id(
async def update_billing_cycle_record(
self,
tenant_id: str
) -> Optional[Subscription]:
tenant_id: str,
new_billing_cycle: str,
new_status: str,
new_period_start: datetime,
new_period_end: datetime,
current_plan: str,
proration_details: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Get subscription by tenant ID
Update local billing cycle record in database
Args:
tenant_id: Tenant ID
new_billing_cycle: New billing cycle ('monthly' or 'yearly')
new_status: New subscription status
new_period_start: New period start date
new_period_end: New period end date
current_plan: Current plan name
proration_details: Proration details from payment provider
Returns:
Subscription object or None
Dictionary with billing cycle update results
"""
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(
# Get current subscription
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}")
# Update local subscription record
update_data = {
'status': new_status,
'current_period_start': new_period_start,
'current_period_end': new_period_end,
'updated_at': datetime.now(timezone.utc)
}
updated_subscription = await self.subscription_repo.update(str(subscription.id), update_data)
# Invalidate subscription cache
await self._invalidate_cache(tenant_id)
old_billing_cycle = getattr(subscription, 'billing_cycle', 'monthly')
logger.info(
"subscription_billing_cycle_record_updated",
tenant_id=str(tenant_id),
old_billing_cycle=old_billing_cycle,
new_billing_cycle=new_billing_cycle,
proration_amount=proration_details.get("net_amount", 0) if proration_details else 0
)
return {
"success": True,
"message": f"Billing cycle record changed to {new_billing_cycle}",
"old_billing_cycle": old_billing_cycle,
"new_billing_cycle": new_billing_cycle,
"proration_details": proration_details,
"new_status": new_status,
"new_period_end": new_period_end.isoformat()
}
except ValidationError as ve:
logger.error("change_billing_cycle_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise ve
except Exception as e:
logger.error("change_billing_cycle_failed", error=str(e), tenant_id=tenant_id)
raise DatabaseError(f"Failed to change billing cycle: {str(e)}")
async def _invalidate_cache(self, tenant_id: str):
"""Helper method to 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",
tenant_id=str(tenant_id)
)
except Exception as cache_error:
logger.error(
"Failed to invalidate subscription cache",
tenant_id=str(tenant_id),
error=str(cache_error)
)
async def validate_subscription_change(
self,
stripe_subscription_id: str
) -> Optional[Subscription]:
tenant_id: str,
new_plan: str
) -> bool:
"""
Get subscription by Stripe subscription ID
Validate if a subscription change is allowed
Args:
stripe_subscription_id: Stripe subscription ID
tenant_id: Tenant ID
new_plan: New plan to validate
Returns:
Subscription object or None
True if change is allowed
"""
try:
return await self.subscription_repo.get_by_stripe_id(stripe_subscription_id)
subscription = await self.get_subscription_by_tenant_id(tenant_id)
if not subscription:
return False
# Can't change if already pending cancellation or inactive
if subscription.status in ['pending_cancellation', 'inactive']:
return False
return True
except Exception as e:
logger.error("get_subscription_by_stripe_id_failed",
error=str(e), stripe_subscription_id=stripe_subscription_id)
return None
logger.error("validate_subscription_change_failed",
error=str(e), tenant_id=tenant_id)
return False
# ========================================================================
# TENANT-INDEPENDENT SUBSCRIPTION METHODS (New Architecture)
# ========================================================================
async def create_tenant_independent_subscription_record(
self,
stripe_subscription_id: str,
stripe_customer_id: str,
plan: str,
status: str,
trial_period_days: Optional[int] = None,
billing_interval: str = "monthly",
user_id: str = None
) -> Subscription:
"""
Create a tenant-independent subscription record in the database
This subscription is not linked to any tenant and will be linked during onboarding
Args:
stripe_subscription_id: Stripe subscription ID
stripe_customer_id: Stripe customer ID
plan: Subscription plan
status: Subscription status
trial_period_days: Optional trial period in days
billing_interval: Billing interval (monthly or yearly)
user_id: User ID who created this subscription
Returns:
Created Subscription object
"""
try:
# Create tenant-independent subscription record
subscription_data = {
'stripe_subscription_id': stripe_subscription_id, # Stripe subscription ID
'stripe_customer_id': stripe_customer_id, # Stripe customer ID
'plan': plan, # Repository expects 'plan', not 'plan_id'
'status': status,
'created_at': datetime.now(timezone.utc),
'trial_period_days': trial_period_days,
'billing_cycle': billing_interval,
'user_id': user_id,
'is_tenant_linked': False,
'tenant_linking_status': 'pending'
}
created_subscription = await self.subscription_repo.create_tenant_independent_subscription(subscription_data)
logger.info("tenant_independent_subscription_record_created",
subscription_id=stripe_subscription_id,
user_id=user_id,
plan=plan)
return created_subscription
except ValidationError as ve:
logger.error("create_tenant_independent_subscription_record_validation_failed",
error=str(ve), user_id=user_id)
raise ve
except Exception as e:
logger.error("create_tenant_independent_subscription_record_failed",
error=str(e), user_id=user_id)
raise DatabaseError(f"Failed to create tenant-independent subscription record: {str(e)}")
async def get_pending_tenant_linking_subscriptions(self) -> List[Subscription]:
"""Get all subscriptions waiting to be linked to tenants"""
try:
return await self.subscription_repo.get_pending_tenant_linking_subscriptions()
except Exception as e:
logger.error("Failed to get pending tenant linking subscriptions", error=str(e))
raise DatabaseError(f"Failed to get pending subscriptions: {str(e)}")
async def get_pending_subscriptions_by_user(self, user_id: str) -> List[Subscription]:
"""Get pending tenant linking subscriptions for a specific user"""
try:
return await self.subscription_repo.get_pending_subscriptions_by_user(user_id)
except Exception as e:
logger.error("Failed to get pending subscriptions by user",
user_id=user_id, error=str(e))
raise DatabaseError(f"Failed to get pending subscriptions: {str(e)}")
async def link_subscription_to_tenant(
self,
subscription_id: str,
tenant_id: str,
user_id: str
) -> Subscription:
"""
Link a pending subscription to a tenant
This completes the registration flow by associating the subscription
created during registration with the tenant created during onboarding
Args:
subscription_id: Subscription ID to link
tenant_id: Tenant ID to link to
user_id: User ID performing the linking (for validation)
Returns:
Updated Subscription object
"""
try:
return await self.subscription_repo.link_subscription_to_tenant(
subscription_id, tenant_id, user_id
)
except Exception as e:
logger.error("Failed to link subscription to tenant",
subscription_id=subscription_id,
tenant_id=tenant_id,
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to link subscription to tenant: {str(e)}")
async def cleanup_orphaned_subscriptions(self, days_old: int = 30) -> int:
"""Clean up subscriptions that were never linked to tenants"""
try:
return await self.subscription_repo.cleanup_orphaned_subscriptions(days_old)
except Exception as e:
logger.error("Failed to cleanup orphaned subscriptions", error=str(e))
raise DatabaseError(f"Failed to cleanup orphaned subscriptions: {str(e)}")

View File

@@ -150,10 +150,13 @@ class EnhancedTenantService:
default_plan=selected_plan)
# Create subscription with selected or default plan
# When tenant_id is set, is_tenant_linked must be True (database constraint)
subscription_data = {
"tenant_id": str(tenant.id),
"plan": selected_plan,
"status": "active"
"status": "active",
"is_tenant_linked": True, # Required when tenant_id is set
"tenant_linking_status": "completed" # Mark as completed since tenant is already created
}
subscription = await subscription_repo.create_subscription(subscription_data)
@@ -188,7 +191,7 @@ class EnhancedTenantService:
from shared.utils.city_normalization import normalize_city_id
from app.core.config import settings
external_client = ExternalServiceClient(settings, "tenant-service")
external_client = ExternalServiceClient(settings, "tenant")
city_id = normalize_city_id(bakery_data.city)
if city_id:
@@ -217,6 +220,24 @@ class EnhancedTenantService:
)
# Don't fail tenant creation if location-context creation fails
# Update user's tenant_id in auth service
try:
from shared.clients.auth_client import AuthServiceClient
from app.core.config import settings
auth_client = AuthServiceClient(settings)
await auth_client.update_user_tenant_id(owner_id, str(tenant.id))
logger.info("Updated user tenant_id in auth service",
user_id=owner_id,
tenant_id=str(tenant.id))
except Exception as e:
logger.error("Failed to update user tenant_id (non-blocking)",
user_id=owner_id,
tenant_id=str(tenant.id),
error=str(e))
# Don't fail tenant creation if user update fails
logger.info("Bakery created successfully",
tenant_id=tenant.id,
name=bakery_data.name,
@@ -1354,5 +1375,108 @@ class EnhancedTenantService:
return []
# ========================================================================
# TENANT-INDEPENDENT SUBSCRIPTION METHODS (New Architecture)
# ========================================================================
async def link_subscription_to_tenant(
self,
tenant_id: str,
subscription_id: str,
user_id: str
) -> Dict[str, Any]:
"""
Link a pending subscription to a tenant
This completes the registration flow by associating the subscription
created during registration with the tenant created during onboarding
Args:
tenant_id: Tenant ID to link to
subscription_id: Subscription ID to link
user_id: User ID performing the linking (for validation)
Returns:
Dictionary with linking results
"""
try:
async with self.database_manager.get_session() as db_session:
async with UnitOfWork(db_session) as uow:
# Register repositories
subscription_repo = uow.register_repository(
"subscriptions", SubscriptionRepository, Subscription
)
tenant_repo = uow.register_repository(
"tenants", TenantRepository, Tenant
)
# Get the subscription
subscription = await subscription_repo.get_by_id(subscription_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subscription not found"
)
# Verify subscription is in pending_tenant_linking state
if subscription.tenant_linking_status != "pending":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Subscription is not in pending tenant linking state"
)
# Verify subscription belongs to this user
if subscription.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Subscription does not belong to this user"
)
# Update subscription with tenant_id
update_data = {
"tenant_id": tenant_id,
"is_tenant_linked": True,
"tenant_linking_status": "completed",
"linked_at": datetime.now(timezone.utc)
}
await subscription_repo.update(subscription_id, update_data)
# Update tenant with subscription information
tenant_update = {
"stripe_customer_id": subscription.customer_id,
"subscription_status": subscription.status,
"subscription_plan": subscription.plan,
"subscription_tier": subscription.plan,
"billing_cycle": subscription.billing_cycle,
"trial_period_days": subscription.trial_period_days
}
await tenant_repo.update_tenant(tenant_id, tenant_update)
# Commit transaction
await uow.commit()
logger.info("Subscription successfully linked to tenant",
tenant_id=tenant_id,
subscription_id=subscription_id,
user_id=user_id)
return {
"success": True,
"tenant_id": tenant_id,
"subscription_id": subscription_id,
"status": "linked"
}
except Exception as e:
logger.error("Failed to link subscription to tenant",
error=str(e),
tenant_id=tenant_id,
subscription_id=subscription_id,
user_id=user_id)
raise
# Legacy compatibility alias
TenantService = EnhancedTenantService