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

@@ -502,3 +502,201 @@ class SubscriptionRepository(TenantBaseRepository):
except Exception as e:
logger.warning("Failed to invalidate cache (non-critical)",
tenant_id=tenant_id, error=str(e))
# ========================================================================
# TENANT-INDEPENDENT SUBSCRIPTION METHODS (New Architecture)
# ========================================================================
async def create_tenant_independent_subscription(
self,
subscription_data: Dict[str, Any]
) -> Subscription:
"""Create a subscription not linked to any tenant (for registration flow)"""
try:
# Validate required data for tenant-independent subscription
required_fields = ["user_id", "plan", "stripe_subscription_id", "stripe_customer_id"]
validation_result = self._validate_tenant_data(subscription_data, required_fields)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid subscription data: {validation_result['errors']}")
# Ensure tenant_id is not provided (this is tenant-independent)
if "tenant_id" in subscription_data and subscription_data["tenant_id"]:
raise ValidationError("tenant_id should not be provided for tenant-independent subscriptions")
# Set tenant-independent specific fields
subscription_data["tenant_id"] = None
subscription_data["is_tenant_linked"] = False
subscription_data["tenant_linking_status"] = "pending"
subscription_data["linked_at"] = None
# Set default values based on plan from centralized configuration
plan = subscription_data["plan"]
plan_info = SubscriptionPlanMetadata.get_plan_info(plan)
# Set defaults from centralized plan configuration
if "monthly_price" not in subscription_data:
billing_cycle = subscription_data.get("billing_cycle", "monthly")
subscription_data["monthly_price"] = float(
PlanPricing.get_price(plan, billing_cycle)
)
if "max_users" not in subscription_data:
subscription_data["max_users"] = QuotaLimits.get_limit('MAX_USERS', plan) or -1
if "max_locations" not in subscription_data:
subscription_data["max_locations"] = QuotaLimits.get_limit('MAX_LOCATIONS', plan) or -1
if "max_products" not in subscription_data:
subscription_data["max_products"] = QuotaLimits.get_limit('MAX_PRODUCTS', plan) or -1
if "features" not in subscription_data:
subscription_data["features"] = {
feature: True for feature in plan_info.get("features", [])
}
# Set default subscription values
if "status" not in subscription_data:
subscription_data["status"] = "pending_tenant_linking"
if "billing_cycle" not in subscription_data:
subscription_data["billing_cycle"] = "monthly"
if "next_billing_date" not in subscription_data:
# Set next billing date based on cycle
if subscription_data["billing_cycle"] == "yearly":
subscription_data["next_billing_date"] = datetime.utcnow() + timedelta(days=365)
else:
subscription_data["next_billing_date"] = datetime.utcnow() + timedelta(days=30)
# Create tenant-independent subscription
subscription = await self.create(subscription_data)
logger.info("Tenant-independent subscription created successfully",
subscription_id=subscription.id,
user_id=subscription.user_id,
plan=subscription.plan,
monthly_price=subscription.monthly_price)
return subscription
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create tenant-independent subscription",
user_id=subscription_data.get("user_id"),
plan=subscription_data.get("plan"),
error=str(e))
raise DatabaseError(f"Failed to create tenant-independent subscription: {str(e)}")
async def get_pending_tenant_linking_subscriptions(self) -> List[Subscription]:
"""Get all subscriptions waiting to be linked to tenants"""
try:
subscriptions = await self.get_multi(
filters={
"tenant_linking_status": "pending",
"is_tenant_linked": False
},
order_by="created_at",
order_desc=True
)
return 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:
subscriptions = await self.get_multi(
filters={
"user_id": user_id,
"tenant_linking_status": "pending",
"is_tenant_linked": False
},
order_by="created_at",
order_desc=True
)
return subscriptions
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"""
try:
# Get the subscription first
subscription = await self.get_by_id(subscription_id)
if not subscription:
raise ValidationError(f"Subscription {subscription_id} not found")
# Validate subscription can be linked
if not subscription.can_be_linked_to_tenant(user_id):
raise ValidationError(
f"Subscription {subscription_id} cannot be linked to tenant by user {user_id}. "
f"Current status: {subscription.tenant_linking_status}, "
f"User: {subscription.user_id}, "
f"Already linked: {subscription.is_tenant_linked}"
)
# Update subscription with tenant information
update_data = {
"tenant_id": tenant_id,
"is_tenant_linked": True,
"tenant_linking_status": "completed",
"linked_at": datetime.utcnow(),
"status": "active", # Activate subscription when linked to tenant
"updated_at": datetime.utcnow()
}
updated_subscription = await self.update(subscription_id, update_data)
# Invalidate cache for the tenant
await self._invalidate_cache(tenant_id)
logger.info("Subscription linked to tenant successfully",
subscription_id=subscription_id,
tenant_id=tenant_id,
user_id=user_id)
return updated_subscription
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:
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
query_text = """
DELETE FROM subscriptions
WHERE tenant_linking_status = 'pending'
AND is_tenant_linked = FALSE
AND created_at < :cutoff_date
"""
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
deleted_count = result.rowcount
logger.info("Cleaned up orphaned subscriptions",
deleted_count=deleted_count,
days_old=days_old)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup orphaned subscriptions",
error=str(e))
raise DatabaseError(f"Cleanup failed: {str(e)}")