Improve the frontend 2

This commit is contained in:
Urtzi Alfaro
2025-10-29 06:58:05 +01:00
parent 858d985c92
commit 36217a2729
98 changed files with 6652 additions and 4230 deletions

View File

@@ -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

View File

@@ -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}",

View File

@@ -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})>"

View File

@@ -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))

View File

@@ -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("""

View File

@@ -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

View 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

View File

@@ -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"
}

View File

@@ -0,0 +1,103 @@
"""remove subscription_tier from tenants
Revision ID: 20251028_remove_sub_tier
Revises: 20251025_supplier_approval
Create Date: 2025-10-28 12:00:00.000000
This migration removes the denormalized subscription_tier column from the tenants table.
The subscription tier is now sourced exclusively from the subscriptions table (single source of truth).
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20251028_remove_sub_tier'
down_revision = '20251025_supplier_approval'
branch_labels = None
depends_on = None
def upgrade():
"""
Remove subscription_tier column from tenants table
"""
# Pre-flight check: Ensure all tenants have active subscriptions
# This is important to avoid breaking the application
connection = op.get_bind()
# Check for tenants without subscriptions
result = connection.execute(sa.text("""
SELECT COUNT(*) as count
FROM tenants t
LEFT JOIN subscriptions s ON t.id = s.tenant_id AND s.status = 'active'
WHERE s.id IS NULL
"""))
orphaned_count = result.fetchone()[0]
if orphaned_count > 0:
# Create default subscriptions for orphaned tenants
connection.execute(sa.text("""
INSERT INTO subscriptions (
id, tenant_id, plan, status, monthly_price, billing_cycle,
max_users, max_locations, max_products, features, created_at, updated_at
)
SELECT
gen_random_uuid(),
t.id,
'starter',
'active',
49.0,
'monthly',
5,
1,
50,
'{"inventory_management": true, "demand_prediction": true}'::jsonb,
NOW(),
NOW()
FROM tenants t
LEFT JOIN subscriptions s ON t.id = s.tenant_id AND s.status = 'active'
WHERE s.id IS NULL
"""))
print(f"Created default subscriptions for {orphaned_count} tenants without subscriptions")
# Drop the subscription_tier column
op.drop_column('tenants', 'subscription_tier')
print("Successfully removed subscription_tier column from tenants table")
def downgrade():
"""
Re-add subscription_tier column and populate from subscriptions table
Note: This is for rollback purposes only. Going forward, always use subscriptions table.
"""
# Add the column back
op.add_column('tenants',
sa.Column('subscription_tier', sa.String(length=50), nullable=True)
)
# Populate from subscriptions table
connection = op.get_bind()
connection.execute(sa.text("""
UPDATE tenants t
SET subscription_tier = s.plan
FROM subscriptions s
WHERE t.id = s.tenant_id
AND s.status = 'active'
"""))
# Set default for any tenants without active subscriptions
connection.execute(sa.text("""
UPDATE tenants
SET subscription_tier = 'starter'
WHERE subscription_tier IS NULL
"""))
# Make it non-nullable after population
op.alter_column('tenants', 'subscription_tier', nullable=False)
print("Restored subscription_tier column (downgrade)")

View File

@@ -55,7 +55,6 @@ TENANTS_DATA = [
"id": DEMO_TENANT_SAN_PABLO,
"name": "Panadería San Pablo",
"business_model": "san_pablo",
"subscription_tier": "demo_template",
"is_demo": False, # Template tenants are not marked as demo
"is_demo_template": True, # They are templates for cloning
"is_active": True,
@@ -88,7 +87,6 @@ TENANTS_DATA = [
"id": DEMO_TENANT_LA_ESPIGA,
"name": "Panadería La Espiga - Obrador Central",
"business_model": "la_espiga",
"subscription_tier": "demo_template",
"is_demo": False,
"is_demo_template": True,
"is_active": True,
@@ -173,6 +171,41 @@ async def seed_tenants(db: AsyncSession) -> dict:
db.add(tenant)
created_count += 1
# Flush to get tenant IDs before creating subscriptions
await db.flush()
# Create demo subscriptions for all tenants (enterprise tier for full demo access)
from app.models.tenants import Subscription
for tenant_data in TENANTS_DATA:
tenant_id = tenant_data["id"]
# Check if subscription already exists
result = await db.execute(
select(Subscription).where(Subscription.tenant_id == tenant_id)
)
existing_subscription = result.scalars().first()
if not existing_subscription:
logger.info(
"Creating demo subscription for tenant",
tenant_id=str(tenant_id),
plan="enterprise"
)
subscription = Subscription(
tenant_id=tenant_id,
plan="enterprise", # Demo templates get 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(subscription)
# Commit all changes
await db.commit()