Improve the frontend 2
This commit is contained in:
@@ -170,7 +170,6 @@ async def clone_demo_data(
|
||||
is_demo=True,
|
||||
is_demo_template=False,
|
||||
business_model=demo_account_type,
|
||||
subscription_tier="demo", # Special tier for demo sessions
|
||||
is_active=True,
|
||||
timezone="Europe/Madrid",
|
||||
owner_id=demo_owner_uuid # Required field - matches seed_demo_users.py
|
||||
@@ -179,6 +178,21 @@ async def clone_demo_data(
|
||||
db.add(tenant)
|
||||
await db.flush() # Flush to get the tenant ID
|
||||
|
||||
# Create demo subscription (enterprise tier for full access)
|
||||
from app.models.tenants import Subscription
|
||||
demo_subscription = Subscription(
|
||||
tenant_id=tenant.id,
|
||||
plan="enterprise", # Demo gets full access
|
||||
status="active",
|
||||
monthly_price=0.0, # Free for demo
|
||||
billing_cycle="monthly",
|
||||
max_users=-1, # Unlimited
|
||||
max_locations=-1,
|
||||
max_products=-1,
|
||||
features={}
|
||||
)
|
||||
db.add(demo_subscription)
|
||||
|
||||
# Create tenant member records for demo owner and staff
|
||||
from app.models.tenants import TenantMember
|
||||
import json
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.schemas.tenants import (
|
||||
from app.services.tenant_service import EnhancedTenantService
|
||||
from app.services.subscription_limit_service import SubscriptionLimitService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.models import AuditLog
|
||||
from shared.auth.decorators import (
|
||||
get_current_user_dep,
|
||||
require_admin_role_dep
|
||||
@@ -33,7 +34,7 @@ router = APIRouter()
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
# Initialize audit logger
|
||||
audit_logger = create_audit_logger("tenant-service")
|
||||
audit_logger = create_audit_logger("tenant-service", AuditLog)
|
||||
|
||||
# Global Redis client
|
||||
_redis_client = None
|
||||
@@ -555,6 +556,73 @@ async def get_tenant_statistics(
|
||||
# SUBSCRIPTION OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/api/v1/subscriptions/{tenant_id}/tier")
|
||||
async def get_tenant_subscription_tier_fast(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
redis_client = Depends(get_tenant_redis_client)
|
||||
):
|
||||
"""
|
||||
Fast cached lookup for tenant subscription tier
|
||||
|
||||
This endpoint is optimized for high-frequency access (e.g., from gateway middleware)
|
||||
with Redis caching (10-minute TTL). No authentication required for internal service calls.
|
||||
"""
|
||||
try:
|
||||
from app.services.subscription_cache import get_subscription_cache_service
|
||||
|
||||
cache_service = get_subscription_cache_service(redis_client)
|
||||
tier = await cache_service.get_tenant_tier_cached(str(tenant_id))
|
||||
|
||||
return {
|
||||
"tenant_id": str(tenant_id),
|
||||
"tier": tier,
|
||||
"cached": True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get subscription tier",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get subscription tier"
|
||||
)
|
||||
|
||||
@router.get(route_builder.build_base_route("subscriptions/{tenant_id}/active", include_tenant_prefix=False))
|
||||
async def get_tenant_active_subscription(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
redis_client = Depends(get_tenant_redis_client)
|
||||
):
|
||||
"""
|
||||
Get full active subscription with caching
|
||||
|
||||
Returns complete subscription details with 10-minute Redis cache.
|
||||
"""
|
||||
try:
|
||||
from app.services.subscription_cache import get_subscription_cache_service
|
||||
|
||||
cache_service = get_subscription_cache_service(redis_client)
|
||||
subscription = await cache_service.get_tenant_subscription_cached(str(tenant_id))
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No active subscription found"
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to get active subscription",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get active subscription"
|
||||
)
|
||||
|
||||
@router.get(route_builder.build_base_route("subscriptions/{tenant_id}/limits", include_tenant_prefix=False))
|
||||
async def get_subscription_limits(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
@@ -760,6 +828,25 @@ async def upgrade_subscription_plan(
|
||||
new_plan=new_plan,
|
||||
user_id=current_user["user_id"])
|
||||
|
||||
# Invalidate subscription cache to ensure immediate availability of new tier
|
||||
try:
|
||||
from app.services.subscription_cache import get_subscription_cache_service
|
||||
import shared.redis_utils
|
||||
from app.core.config import settings
|
||||
|
||||
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 upgrade",
|
||||
tenant_id=str(tenant_id),
|
||||
new_plan=new_plan)
|
||||
except Exception as cache_error:
|
||||
logger.error("Failed to invalidate subscription cache after upgrade",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(cache_error))
|
||||
# Don't fail the upgrade if cache invalidation fails
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Plan successfully upgraded to {new_plan}",
|
||||
|
||||
@@ -38,7 +38,6 @@ class Tenant(Base):
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
subscription_tier = Column(String(50), default="starter")
|
||||
|
||||
# Demo account flags
|
||||
is_demo = Column(Boolean, default=False, index=True)
|
||||
@@ -63,9 +62,24 @@ class Tenant(Base):
|
||||
|
||||
# Relationships - only within tenant service
|
||||
members = relationship("TenantMember", back_populates="tenant", cascade="all, delete-orphan")
|
||||
|
||||
subscriptions = relationship("Subscription", back_populates="tenant", cascade="all, delete-orphan")
|
||||
|
||||
# REMOVED: users relationship - no cross-service SQLAlchemy relationships
|
||||
|
||||
|
||||
@property
|
||||
def subscription_tier(self):
|
||||
"""
|
||||
Get current subscription tier from active subscription
|
||||
|
||||
Note: This is a computed property that requires subscription relationship to be loaded.
|
||||
For performance-critical operations, use the subscription cache service directly.
|
||||
"""
|
||||
# Find active subscription
|
||||
for subscription in self.subscriptions:
|
||||
if subscription.status == 'active':
|
||||
return subscription.plan
|
||||
return "starter" # Default fallback
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant(id={self.id}, name={self.name})>"
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import json
|
||||
from .base import TenantBaseRepository
|
||||
from app.models.tenants import Subscription
|
||||
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
|
||||
from shared.subscription.plans import SubscriptionPlanMetadata, QuotaLimits, PlanPricing
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -44,13 +45,30 @@ class SubscriptionRepository(TenantBaseRepository):
|
||||
if existing_subscription:
|
||||
raise DuplicateRecordError(f"Tenant already has an active subscription")
|
||||
|
||||
# Set default values based on plan
|
||||
plan_config = self._get_plan_configuration(subscription_data["plan"])
|
||||
|
||||
# Set defaults from plan config
|
||||
for key, value in plan_config.items():
|
||||
if key not in subscription_data:
|
||||
subscription_data[key] = value
|
||||
# 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:
|
||||
@@ -129,37 +147,47 @@ class SubscriptionRepository(TenantBaseRepository):
|
||||
async def update_subscription_plan(
|
||||
self,
|
||||
subscription_id: str,
|
||||
new_plan: str
|
||||
new_plan: str,
|
||||
billing_cycle: str = "monthly"
|
||||
) -> Optional[Subscription]:
|
||||
"""Update subscription plan and pricing"""
|
||||
"""Update subscription plan and pricing using centralized configuration"""
|
||||
try:
|
||||
valid_plans = ["starter", "professional", "enterprise"]
|
||||
if new_plan not in valid_plans:
|
||||
raise ValidationError(f"Invalid plan. Must be one of: {valid_plans}")
|
||||
|
||||
# Get new plan configuration
|
||||
plan_config = self._get_plan_configuration(new_plan)
|
||||
|
||||
|
||||
# Get current subscription to find tenant_id for cache invalidation
|
||||
subscription = await self.get_by_id(subscription_id)
|
||||
if not subscription:
|
||||
raise ValidationError(f"Subscription {subscription_id} not found")
|
||||
|
||||
# Get new plan configuration from centralized source
|
||||
plan_info = SubscriptionPlanMetadata.get_plan_info(new_plan)
|
||||
|
||||
# Update subscription with new plan details
|
||||
update_data = {
|
||||
"plan": new_plan,
|
||||
"monthly_price": plan_config["monthly_price"],
|
||||
"max_users": plan_config["max_users"],
|
||||
"max_locations": plan_config["max_locations"],
|
||||
"max_products": plan_config["max_products"],
|
||||
"features": plan_config.get("features", {}),
|
||||
"monthly_price": float(PlanPricing.get_price(new_plan, billing_cycle)),
|
||||
"billing_cycle": billing_cycle,
|
||||
"max_users": QuotaLimits.get_limit('MAX_USERS', new_plan) or -1,
|
||||
"max_locations": QuotaLimits.get_limit('MAX_LOCATIONS', new_plan) or -1,
|
||||
"max_products": QuotaLimits.get_limit('MAX_PRODUCTS', new_plan) or -1,
|
||||
"features": {feature: True for feature in plan_info.get("features", [])},
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
|
||||
|
||||
updated_subscription = await self.update(subscription_id, update_data)
|
||||
|
||||
|
||||
# Invalidate cache
|
||||
await self._invalidate_cache(str(subscription.tenant_id))
|
||||
|
||||
logger.info("Subscription plan updated",
|
||||
subscription_id=subscription_id,
|
||||
new_plan=new_plan,
|
||||
new_price=plan_config["monthly_price"])
|
||||
|
||||
new_price=update_data["monthly_price"])
|
||||
|
||||
return updated_subscription
|
||||
|
||||
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -176,19 +204,27 @@ class SubscriptionRepository(TenantBaseRepository):
|
||||
) -> Optional[Subscription]:
|
||||
"""Cancel a subscription"""
|
||||
try:
|
||||
# Get subscription to find tenant_id for cache invalidation
|
||||
subscription = await self.get_by_id(subscription_id)
|
||||
if not subscription:
|
||||
raise ValidationError(f"Subscription {subscription_id} not found")
|
||||
|
||||
update_data = {
|
||||
"status": "cancelled",
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
|
||||
|
||||
updated_subscription = await self.update(subscription_id, update_data)
|
||||
|
||||
|
||||
# Invalidate cache
|
||||
await self._invalidate_cache(str(subscription.tenant_id))
|
||||
|
||||
logger.info("Subscription cancelled",
|
||||
subscription_id=subscription_id,
|
||||
reason=reason)
|
||||
|
||||
|
||||
return updated_subscription
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to cancel subscription",
|
||||
subscription_id=subscription_id,
|
||||
@@ -202,12 +238,20 @@ class SubscriptionRepository(TenantBaseRepository):
|
||||
) -> Optional[Subscription]:
|
||||
"""Suspend a subscription"""
|
||||
try:
|
||||
# Get subscription to find tenant_id for cache invalidation
|
||||
subscription = await self.get_by_id(subscription_id)
|
||||
if not subscription:
|
||||
raise ValidationError(f"Subscription {subscription_id} not found")
|
||||
|
||||
update_data = {
|
||||
"status": "suspended",
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
|
||||
updated_subscription = await self.update(subscription_id, update_data)
|
||||
|
||||
# Invalidate cache
|
||||
await self._invalidate_cache(str(subscription.tenant_id))
|
||||
|
||||
logger.info("Subscription suspended",
|
||||
subscription_id=subscription_id,
|
||||
@@ -227,23 +271,31 @@ class SubscriptionRepository(TenantBaseRepository):
|
||||
) -> Optional[Subscription]:
|
||||
"""Reactivate a cancelled or suspended subscription"""
|
||||
try:
|
||||
# Get subscription to find tenant_id for cache invalidation
|
||||
subscription = await self.get_by_id(subscription_id)
|
||||
if not subscription:
|
||||
raise ValidationError(f"Subscription {subscription_id} not found")
|
||||
|
||||
# Reset billing date when reactivating
|
||||
next_billing_date = datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
|
||||
update_data = {
|
||||
"status": "active",
|
||||
"next_billing_date": next_billing_date,
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
|
||||
|
||||
updated_subscription = await self.update(subscription_id, update_data)
|
||||
|
||||
|
||||
# Invalidate cache
|
||||
await self._invalidate_cache(str(subscription.tenant_id))
|
||||
|
||||
logger.info("Subscription reactivated",
|
||||
subscription_id=subscription_id,
|
||||
next_billing_date=next_billing_date)
|
||||
|
||||
|
||||
return updated_subscription
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to reactivate subscription",
|
||||
subscription_id=subscription_id,
|
||||
@@ -394,63 +446,23 @@ class SubscriptionRepository(TenantBaseRepository):
|
||||
logger.error("Failed to cleanup old subscriptions",
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Cleanup failed: {str(e)}")
|
||||
|
||||
def _get_plan_configuration(self, plan: str) -> Dict[str, Any]:
|
||||
"""Get configuration for a subscription plan"""
|
||||
plan_configs = {
|
||||
"starter": {
|
||||
"monthly_price": 49.0,
|
||||
"max_users": 5, # Reasonable for small bakeries
|
||||
"max_locations": 1,
|
||||
"max_products": 50,
|
||||
"features": {
|
||||
"inventory_management": "basic",
|
||||
"demand_prediction": "basic",
|
||||
"production_reports": "basic",
|
||||
"analytics": "basic",
|
||||
"support": "email",
|
||||
"trial_days": 14,
|
||||
"locations": "1_location",
|
||||
"ai_model_configuration": "basic" # Added AI model configuration for all tiers
|
||||
}
|
||||
},
|
||||
"professional": {
|
||||
"monthly_price": 129.0,
|
||||
"max_users": 15, # Good for growing bakeries
|
||||
"max_locations": 2,
|
||||
"max_products": -1, # Unlimited products
|
||||
"features": {
|
||||
"inventory_management": "advanced",
|
||||
"demand_prediction": "ai_92_percent",
|
||||
"production_management": "complete",
|
||||
"pos_integrated": True,
|
||||
"logistics": "basic",
|
||||
"analytics": "advanced",
|
||||
"support": "priority_24_7",
|
||||
"trial_days": 14,
|
||||
"locations": "1_2_locations",
|
||||
"ai_model_configuration": "advanced" # Enhanced AI model configuration for Professional
|
||||
}
|
||||
},
|
||||
"enterprise": {
|
||||
"monthly_price": 399.0,
|
||||
"max_users": -1, # Unlimited users
|
||||
"max_locations": -1, # Unlimited locations
|
||||
"max_products": -1, # Unlimited products
|
||||
"features": {
|
||||
"inventory_management": "multi_location",
|
||||
"demand_prediction": "ai_personalized",
|
||||
"production_optimization": "capacity",
|
||||
"erp_integration": True,
|
||||
"logistics": "advanced",
|
||||
"analytics": "predictive",
|
||||
"api_access": "personalized",
|
||||
"account_manager": True,
|
||||
"demo": "personalized",
|
||||
"locations": "unlimited_obradores",
|
||||
"ai_model_configuration": "enterprise" # Full AI model configuration for Enterprise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return plan_configs.get(plan, plan_configs["starter"])
|
||||
|
||||
async def _invalidate_cache(self, tenant_id: str) -> None:
|
||||
"""
|
||||
Invalidate subscription cache for a tenant
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
"""
|
||||
try:
|
||||
from app.services.subscription_cache import get_subscription_cache_service
|
||||
|
||||
cache_service = get_subscription_cache_service()
|
||||
await cache_service.invalidate_subscription_cache(tenant_id)
|
||||
|
||||
logger.debug("Invalidated subscription cache from repository",
|
||||
tenant_id=tenant_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to invalidate cache (non-critical)",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
|
||||
@@ -53,8 +53,6 @@ class TenantRepository(TenantBaseRepository):
|
||||
tenant_data["city"] = "Madrid"
|
||||
if "is_active" not in tenant_data:
|
||||
tenant_data["is_active"] = True
|
||||
if "subscription_tier" not in tenant_data:
|
||||
tenant_data["subscription_tier"] = "basic"
|
||||
if "ml_model_trained" not in tenant_data:
|
||||
tenant_data["ml_model_trained"] = False
|
||||
|
||||
@@ -189,35 +187,6 @@ class TenantRepository(TenantBaseRepository):
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Failed to update model status: {str(e)}")
|
||||
|
||||
async def update_subscription_tier(
|
||||
self,
|
||||
tenant_id: str,
|
||||
subscription_tier: str
|
||||
) -> Optional[Tenant]:
|
||||
"""Update tenant subscription tier"""
|
||||
try:
|
||||
valid_tiers = ["basic", "professional", "enterprise"]
|
||||
if subscription_tier not in valid_tiers:
|
||||
raise ValidationError(f"Invalid subscription tier. Must be one of: {valid_tiers}")
|
||||
|
||||
updated_tenant = await self.update(tenant_id, {
|
||||
"subscription_tier": subscription_tier,
|
||||
"updated_at": datetime.utcnow()
|
||||
})
|
||||
|
||||
logger.info("Tenant subscription tier updated",
|
||||
tenant_id=tenant_id,
|
||||
subscription_tier=subscription_tier)
|
||||
|
||||
return updated_tenant
|
||||
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to update subscription tier",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e))
|
||||
raise DatabaseError(f"Failed to update subscription: {str(e)}")
|
||||
|
||||
async def get_tenants_by_location(
|
||||
self,
|
||||
@@ -291,17 +260,21 @@ class TenantRepository(TenantBaseRepository):
|
||||
result = await self.session.execute(business_type_query)
|
||||
business_type_stats = {row.business_type: row.count for row in result.fetchall()}
|
||||
|
||||
# Get tenants by subscription tier
|
||||
# Get tenants by subscription tier - now from subscriptions table
|
||||
tier_query = text("""
|
||||
SELECT subscription_tier, COUNT(*) as count
|
||||
FROM tenants
|
||||
WHERE is_active = true
|
||||
GROUP BY subscription_tier
|
||||
SELECT s.plan as subscription_tier, COUNT(*) as count
|
||||
FROM tenants t
|
||||
LEFT JOIN subscriptions s ON t.id = s.tenant_id AND s.status = 'active'
|
||||
WHERE t.is_active = true
|
||||
GROUP BY s.plan
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
|
||||
tier_result = await self.session.execute(tier_query)
|
||||
tier_stats = {row.subscription_tier: row.count for row in tier_result.fetchall()}
|
||||
tier_stats = {}
|
||||
for row in tier_result.fetchall():
|
||||
tier = row.subscription_tier if row.subscription_tier else "no_subscription"
|
||||
tier_stats[tier] = row.count
|
||||
|
||||
# Get model training statistics
|
||||
model_query = text("""
|
||||
|
||||
@@ -57,7 +57,7 @@ class BakeryRegistration(BaseModel):
|
||||
return v
|
||||
|
||||
class TenantResponse(BaseModel):
|
||||
"""Tenant response schema - FIXED VERSION"""
|
||||
"""Tenant response schema - Updated to use subscription relationship"""
|
||||
id: str # ✅ Keep as str for Pydantic validation
|
||||
name: str
|
||||
subdomain: Optional[str]
|
||||
@@ -68,12 +68,12 @@ class TenantResponse(BaseModel):
|
||||
postal_code: str
|
||||
phone: Optional[str]
|
||||
is_active: bool
|
||||
subscription_tier: str
|
||||
subscription_plan: Optional[str] = None # Populated from subscription relationship or service
|
||||
ml_model_trained: bool
|
||||
last_training_date: Optional[datetime]
|
||||
owner_id: str # ✅ Keep as str for Pydantic validation
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ✅ FIX: Add custom validator to convert UUID to string
|
||||
@field_validator('id', 'owner_id', mode='before')
|
||||
@classmethod
|
||||
@@ -82,7 +82,7 @@ class TenantResponse(BaseModel):
|
||||
if isinstance(v, UUID):
|
||||
return str(v)
|
||||
return v
|
||||
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
258
services/tenant/app/services/subscription_cache.py
Normal file
258
services/tenant/app/services/subscription_cache.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Subscription Cache Service
|
||||
Provides Redis-based caching for subscription data with 10-minute TTL
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import Optional, Dict, Any
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.repositories import SubscriptionRepository
|
||||
from app.models.tenants import Subscription
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Cache TTL in seconds (10 minutes)
|
||||
SUBSCRIPTION_CACHE_TTL = 600
|
||||
|
||||
|
||||
class SubscriptionCacheService:
|
||||
"""Service for cached subscription lookups"""
|
||||
|
||||
def __init__(self, redis_client=None, database_manager=None):
|
||||
self.redis = redis_client
|
||||
self.database_manager = database_manager
|
||||
|
||||
async def ensure_database_manager(self):
|
||||
"""Ensure database manager is properly initialized"""
|
||||
if self.database_manager is None:
|
||||
from app.core.config import settings
|
||||
from shared.database.base import create_database_manager
|
||||
self.database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
|
||||
async def get_tenant_tier_cached(self, tenant_id: str) -> str:
|
||||
"""
|
||||
Get tenant subscription tier with Redis caching
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
|
||||
Returns:
|
||||
Subscription tier (starter, professional, enterprise)
|
||||
"""
|
||||
try:
|
||||
# Ensure database manager is initialized
|
||||
await self.ensure_database_manager()
|
||||
|
||||
cache_key = f"subscription:tier:{tenant_id}"
|
||||
|
||||
# Try to get from cache
|
||||
if self.redis:
|
||||
try:
|
||||
cached_tier = await self.redis.get(cache_key)
|
||||
if cached_tier:
|
||||
logger.debug("Subscription tier cache hit", tenant_id=tenant_id, tier=cached_tier)
|
||||
return cached_tier.decode('utf-8') if isinstance(cached_tier, bytes) else cached_tier
|
||||
except Exception as e:
|
||||
logger.warning("Redis cache read failed, falling back to database",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
|
||||
# Cache miss or Redis unavailable - fetch from database
|
||||
logger.debug("Subscription tier cache miss", tenant_id=tenant_id)
|
||||
|
||||
async with self.database_manager.get_session() as db_session:
|
||||
subscription_repo = SubscriptionRepository(Subscription, db_session)
|
||||
subscription = await subscription_repo.get_active_subscription(tenant_id)
|
||||
|
||||
if not subscription:
|
||||
logger.warning("No active subscription found, returning starter tier",
|
||||
tenant_id=tenant_id)
|
||||
return "starter"
|
||||
|
||||
tier = subscription.plan
|
||||
|
||||
# Cache the result
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.setex(cache_key, SUBSCRIPTION_CACHE_TTL, tier)
|
||||
logger.debug("Cached subscription tier", tenant_id=tenant_id, tier=tier)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to cache subscription tier",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
|
||||
return tier
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get subscription tier",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
return "starter" # Fallback to starter on error
|
||||
|
||||
async def get_tenant_subscription_cached(self, tenant_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get full tenant subscription with Redis caching
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
|
||||
Returns:
|
||||
Subscription data as dictionary or None
|
||||
"""
|
||||
try:
|
||||
# Ensure database manager is initialized
|
||||
await self.ensure_database_manager()
|
||||
|
||||
cache_key = f"subscription:full:{tenant_id}"
|
||||
|
||||
# Try to get from cache
|
||||
if self.redis:
|
||||
try:
|
||||
cached_data = await self.redis.get(cache_key)
|
||||
if cached_data:
|
||||
logger.debug("Subscription cache hit", tenant_id=tenant_id)
|
||||
data = json.loads(cached_data.decode('utf-8') if isinstance(cached_data, bytes) else cached_data)
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning("Redis cache read failed, falling back to database",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
|
||||
# Cache miss or Redis unavailable - fetch from database
|
||||
logger.debug("Subscription cache miss", tenant_id=tenant_id)
|
||||
|
||||
async with self.database_manager.get_session() as db_session:
|
||||
subscription_repo = SubscriptionRepository(Subscription, db_session)
|
||||
subscription = await subscription_repo.get_active_subscription(tenant_id)
|
||||
|
||||
if not subscription:
|
||||
logger.warning("No active subscription found", tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
# Convert to dictionary
|
||||
subscription_data = {
|
||||
"id": str(subscription.id),
|
||||
"tenant_id": str(subscription.tenant_id),
|
||||
"plan": subscription.plan,
|
||||
"status": subscription.status,
|
||||
"monthly_price": subscription.monthly_price,
|
||||
"billing_cycle": subscription.billing_cycle,
|
||||
"next_billing_date": subscription.next_billing_date.isoformat() if subscription.next_billing_date else None,
|
||||
"trial_ends_at": subscription.trial_ends_at.isoformat() if subscription.trial_ends_at else None,
|
||||
"cancelled_at": subscription.cancelled_at.isoformat() if subscription.cancelled_at else None,
|
||||
"cancellation_effective_date": subscription.cancellation_effective_date.isoformat() if subscription.cancellation_effective_date else None,
|
||||
"max_users": subscription.max_users,
|
||||
"max_locations": subscription.max_locations,
|
||||
"max_products": subscription.max_products,
|
||||
"features": subscription.features,
|
||||
"created_at": subscription.created_at.isoformat() if subscription.created_at else None,
|
||||
"updated_at": subscription.updated_at.isoformat() if subscription.updated_at else None
|
||||
}
|
||||
|
||||
# Cache the result
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.setex(
|
||||
cache_key,
|
||||
SUBSCRIPTION_CACHE_TTL,
|
||||
json.dumps(subscription_data)
|
||||
)
|
||||
logger.debug("Cached subscription data", tenant_id=tenant_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to cache subscription data",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
|
||||
return subscription_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get subscription",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
return None
|
||||
|
||||
async def invalidate_subscription_cache(self, tenant_id: str) -> None:
|
||||
"""
|
||||
Invalidate subscription cache for a tenant
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
"""
|
||||
try:
|
||||
if not self.redis:
|
||||
logger.debug("Redis not available, skipping cache invalidation",
|
||||
tenant_id=tenant_id)
|
||||
return
|
||||
|
||||
tier_key = f"subscription:tier:{tenant_id}"
|
||||
full_key = f"subscription:full:{tenant_id}"
|
||||
|
||||
# Delete both cache keys
|
||||
await self.redis.delete(tier_key, full_key)
|
||||
|
||||
logger.info("Invalidated subscription cache",
|
||||
tenant_id=tenant_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to invalidate subscription cache",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
|
||||
async def warm_cache(self, tenant_id: str) -> None:
|
||||
"""
|
||||
Pre-warm the cache by loading subscription data
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
"""
|
||||
try:
|
||||
logger.debug("Warming subscription cache", tenant_id=tenant_id)
|
||||
|
||||
# Load both tier and full subscription to cache
|
||||
await self.get_tenant_tier_cached(tenant_id)
|
||||
await self.get_tenant_subscription_cached(tenant_id)
|
||||
|
||||
logger.info("Subscription cache warmed", tenant_id=tenant_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to warm subscription cache",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
|
||||
|
||||
# Singleton instance for easy access
|
||||
_cache_service_instance: Optional[SubscriptionCacheService] = None
|
||||
|
||||
|
||||
def get_subscription_cache_service(redis_client=None) -> SubscriptionCacheService:
|
||||
"""
|
||||
Get or create subscription cache service singleton
|
||||
|
||||
Args:
|
||||
redis_client: Optional Redis client
|
||||
|
||||
Returns:
|
||||
SubscriptionCacheService instance
|
||||
"""
|
||||
global _cache_service_instance
|
||||
|
||||
if _cache_service_instance is None:
|
||||
from shared.redis_utils import initialize_redis
|
||||
from app.core.config import settings
|
||||
import asyncio
|
||||
|
||||
# Initialize Redis client if not provided
|
||||
redis_client_instance = None
|
||||
if redis_client is None:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if not loop.is_running():
|
||||
redis_client_instance = asyncio.run(initialize_redis(settings.REDIS_URL))
|
||||
else:
|
||||
# If event loop is running, we can't use asyncio.run
|
||||
# This is a limitation, but we'll handle it by not initializing Redis here
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
redis_client_instance = redis_client
|
||||
|
||||
_cache_service_instance = SubscriptionCacheService(redis_client=redis_client_instance)
|
||||
elif redis_client is not None and _cache_service_instance.redis is None:
|
||||
_cache_service_instance.redis = redis_client
|
||||
|
||||
return _cache_service_instance
|
||||
@@ -251,26 +251,34 @@ class SubscriptionLimitService:
|
||||
return {"can_upgrade": False, "reason": f"Invalid plan: {new_plan}"}
|
||||
|
||||
# Check current usage against new plan limits
|
||||
from app.repositories.subscription_repository import SubscriptionRepository
|
||||
temp_repo = SubscriptionRepository(Subscription, db_session)
|
||||
new_plan_config = temp_repo._get_plan_configuration(new_plan)
|
||||
from shared.subscription.plans import SubscriptionPlanMetadata, PlanPricing
|
||||
new_plan_config = SubscriptionPlanMetadata.get_plan_info(new_plan)
|
||||
|
||||
# Get the max_users limit from the plan limits
|
||||
plan_limits = new_plan_config.get('limits', {})
|
||||
max_users_limit = plan_limits.get('users', 5) # Default to 5 if not specified
|
||||
# Convert "Unlimited" string to None for comparison
|
||||
if max_users_limit == "Unlimited":
|
||||
max_users_limit = None
|
||||
elif max_users_limit is None:
|
||||
max_users_limit = -1 # Use -1 to represent unlimited in the comparison
|
||||
|
||||
# Check if current usage fits new plan
|
||||
members = await self.member_repo.get_tenant_members(tenant_id, active_only=True)
|
||||
current_users = len(members)
|
||||
|
||||
if new_plan_config["max_users"] != -1 and current_users > new_plan_config["max_users"]:
|
||||
if max_users_limit is not None and max_users_limit != -1 and current_users > max_users_limit:
|
||||
return {
|
||||
"can_upgrade": False,
|
||||
"reason": f"Current usage ({current_users} users) exceeds {new_plan} plan limits ({new_plan_config['max_users']} users)"
|
||||
"reason": f"Current usage ({current_users} users) exceeds {new_plan} plan limits ({max_users_limit} users)"
|
||||
}
|
||||
|
||||
return {
|
||||
"can_upgrade": True,
|
||||
"current_plan": current_subscription.plan,
|
||||
"new_plan": new_plan,
|
||||
"price_change": new_plan_config["monthly_price"] - current_subscription.monthly_price,
|
||||
"new_features": new_plan_config.get("features", {}),
|
||||
"price_change": float(PlanPricing.get_price(new_plan)) - current_subscription.monthly_price,
|
||||
"new_features": new_plan_config.get("features", []),
|
||||
"reason": "Upgrade validation successful"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user