Add subcription feature
This commit is contained in:
@@ -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"
|
||||
]
|
||||
108
services/tenant/app/services/coupon_service.py
Normal file
108
services/tenant/app/services/coupon_service.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
1167
services/tenant/app/services/subscription_orchestration_service.py
Normal file
1167
services/tenant/app/services/subscription_orchestration_service.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)}")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user