2025-10-15 16:12:49 +02:00
|
|
|
"""
|
|
|
|
|
Subscription Plans API
|
|
|
|
|
Public endpoint for fetching available subscription plans
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
|
from typing import Dict, Any
|
|
|
|
|
import structlog
|
|
|
|
|
|
|
|
|
|
from shared.subscription.plans import (
|
|
|
|
|
SubscriptionTier,
|
|
|
|
|
SubscriptionPlanMetadata,
|
|
|
|
|
PlanPricing,
|
|
|
|
|
QuotaLimits,
|
2025-11-15 15:20:10 +01:00
|
|
|
PlanFeatures,
|
|
|
|
|
FeatureCategories,
|
|
|
|
|
UserFacingFeatures
|
2025-10-15 16:12:49 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
router = APIRouter(prefix="/plans", tags=["subscription-plans"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("", response_model=Dict[str, Any])
|
|
|
|
|
async def get_available_plans():
|
|
|
|
|
"""
|
|
|
|
|
Get all available subscription plans with complete metadata
|
|
|
|
|
|
|
|
|
|
**Public endpoint** - No authentication required
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary containing plan metadata for all tiers
|
|
|
|
|
|
|
|
|
|
Example Response:
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"plans": {
|
|
|
|
|
"starter": {
|
|
|
|
|
"name": "Starter",
|
|
|
|
|
"description": "Perfect for small bakeries getting started",
|
|
|
|
|
"monthly_price": 49.00,
|
|
|
|
|
"yearly_price": 490.00,
|
|
|
|
|
"features": [...],
|
|
|
|
|
"limits": {...}
|
|
|
|
|
},
|
|
|
|
|
...
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
plans_data = {}
|
|
|
|
|
|
|
|
|
|
for tier in SubscriptionTier:
|
|
|
|
|
metadata = SubscriptionPlanMetadata.PLANS[tier]
|
|
|
|
|
|
|
|
|
|
# Convert Decimal to float for JSON serialization
|
|
|
|
|
plans_data[tier.value] = {
|
|
|
|
|
"name": metadata["name"],
|
2025-11-15 15:20:10 +01:00
|
|
|
"description_key": metadata["description_key"],
|
|
|
|
|
"tagline_key": metadata["tagline_key"],
|
2025-10-15 16:12:49 +02:00
|
|
|
"popular": metadata["popular"],
|
|
|
|
|
"monthly_price": float(metadata["monthly_price"]),
|
|
|
|
|
"yearly_price": float(metadata["yearly_price"]),
|
|
|
|
|
"trial_days": metadata["trial_days"],
|
|
|
|
|
"features": metadata["features"],
|
2025-11-15 15:20:10 +01:00
|
|
|
"hero_features": metadata.get("hero_features", []),
|
|
|
|
|
"roi_badge": metadata.get("roi_badge"),
|
|
|
|
|
"business_metrics": metadata.get("business_metrics"),
|
|
|
|
|
"limits": metadata["limits"],
|
|
|
|
|
"support_key": metadata["support_key"],
|
|
|
|
|
"recommended_for_key": metadata["recommended_for_key"],
|
2025-10-15 16:12:49 +02:00
|
|
|
"contact_sales": metadata.get("contact_sales", False),
|
2025-11-15 15:20:10 +01:00
|
|
|
"custom_pricing": metadata.get("custom_pricing", False),
|
2025-10-15 16:12:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info("subscription_plans_fetched", tier_count=len(plans_data))
|
|
|
|
|
|
|
|
|
|
return {"plans": plans_data}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("failed_to_fetch_plans", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=500,
|
|
|
|
|
detail="Failed to fetch subscription plans"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{tier}", response_model=Dict[str, Any])
|
|
|
|
|
async def get_plan_by_tier(tier: str):
|
|
|
|
|
"""
|
|
|
|
|
Get metadata for a specific subscription tier
|
|
|
|
|
|
|
|
|
|
**Public endpoint** - No authentication required
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tier: Subscription tier (starter, professional, enterprise)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Plan metadata for the specified tier
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
404: If tier is not found
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Validate tier
|
|
|
|
|
tier_enum = SubscriptionTier(tier.lower())
|
|
|
|
|
|
|
|
|
|
metadata = SubscriptionPlanMetadata.PLANS[tier_enum]
|
|
|
|
|
|
|
|
|
|
plan_data = {
|
|
|
|
|
"tier": tier_enum.value,
|
|
|
|
|
"name": metadata["name"],
|
2025-11-15 15:20:10 +01:00
|
|
|
"description_key": metadata["description_key"],
|
|
|
|
|
"tagline_key": metadata["tagline_key"],
|
2025-10-15 16:12:49 +02:00
|
|
|
"popular": metadata["popular"],
|
|
|
|
|
"monthly_price": float(metadata["monthly_price"]),
|
|
|
|
|
"yearly_price": float(metadata["yearly_price"]),
|
|
|
|
|
"trial_days": metadata["trial_days"],
|
|
|
|
|
"features": metadata["features"],
|
2025-11-15 15:20:10 +01:00
|
|
|
"hero_features": metadata.get("hero_features", []),
|
|
|
|
|
"roi_badge": metadata.get("roi_badge"),
|
|
|
|
|
"business_metrics": metadata.get("business_metrics"),
|
|
|
|
|
"limits": metadata["limits"],
|
|
|
|
|
"support_key": metadata["support_key"],
|
|
|
|
|
"recommended_for_key": metadata["recommended_for_key"],
|
2025-10-15 16:12:49 +02:00
|
|
|
"contact_sales": metadata.get("contact_sales", False),
|
2025-11-15 15:20:10 +01:00
|
|
|
"custom_pricing": metadata.get("custom_pricing", False),
|
2025-10-15 16:12:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info("subscription_plan_fetched", tier=tier)
|
|
|
|
|
|
|
|
|
|
return plan_data
|
|
|
|
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=404,
|
|
|
|
|
detail=f"Subscription tier '{tier}' not found"
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("failed_to_fetch_plan", tier=tier, error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=500,
|
|
|
|
|
detail="Failed to fetch subscription plan"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{tier}/features")
|
|
|
|
|
async def get_plan_features(tier: str):
|
|
|
|
|
"""
|
|
|
|
|
Get all features available in a subscription tier
|
|
|
|
|
|
|
|
|
|
**Public endpoint** - No authentication required
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tier: Subscription tier (starter, professional, enterprise)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List of feature keys available in the tier
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
tier_enum = SubscriptionTier(tier.lower())
|
|
|
|
|
features = PlanFeatures.get_features(tier_enum.value)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"tier": tier_enum.value,
|
|
|
|
|
"features": features,
|
|
|
|
|
"feature_count": len(features)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=404,
|
|
|
|
|
detail=f"Subscription tier '{tier}' not found"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{tier}/limits")
|
|
|
|
|
async def get_plan_limits(tier: str):
|
|
|
|
|
"""
|
|
|
|
|
Get all quota limits for a subscription tier
|
|
|
|
|
|
|
|
|
|
**Public endpoint** - No authentication required
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tier: Subscription tier (starter, professional, enterprise)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
All quota limits for the tier
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
tier_enum = SubscriptionTier(tier.lower())
|
|
|
|
|
|
|
|
|
|
limits = {
|
|
|
|
|
"tier": tier_enum.value,
|
|
|
|
|
"team_and_organization": {
|
|
|
|
|
"max_users": QuotaLimits.MAX_USERS[tier_enum],
|
|
|
|
|
"max_locations": QuotaLimits.MAX_LOCATIONS[tier_enum],
|
|
|
|
|
},
|
|
|
|
|
"product_and_inventory": {
|
|
|
|
|
"max_products": QuotaLimits.MAX_PRODUCTS[tier_enum],
|
|
|
|
|
"max_recipes": QuotaLimits.MAX_RECIPES[tier_enum],
|
|
|
|
|
"max_suppliers": QuotaLimits.MAX_SUPPLIERS[tier_enum],
|
|
|
|
|
},
|
|
|
|
|
"ml_and_analytics": {
|
|
|
|
|
"training_jobs_per_day": QuotaLimits.TRAINING_JOBS_PER_DAY[tier_enum],
|
|
|
|
|
"forecast_generation_per_day": QuotaLimits.FORECAST_GENERATION_PER_DAY[tier_enum],
|
|
|
|
|
"dataset_size_rows": QuotaLimits.DATASET_SIZE_ROWS[tier_enum],
|
|
|
|
|
"forecast_horizon_days": QuotaLimits.FORECAST_HORIZON_DAYS[tier_enum],
|
|
|
|
|
"historical_data_access_days": QuotaLimits.HISTORICAL_DATA_ACCESS_DAYS[tier_enum],
|
|
|
|
|
},
|
|
|
|
|
"import_export": {
|
|
|
|
|
"bulk_import_rows": QuotaLimits.BULK_IMPORT_ROWS[tier_enum],
|
|
|
|
|
"bulk_export_rows": QuotaLimits.BULK_EXPORT_ROWS[tier_enum],
|
|
|
|
|
},
|
|
|
|
|
"integrations": {
|
|
|
|
|
"pos_sync_interval_minutes": QuotaLimits.POS_SYNC_INTERVAL_MINUTES[tier_enum],
|
|
|
|
|
"api_calls_per_hour": QuotaLimits.API_CALLS_PER_HOUR[tier_enum],
|
|
|
|
|
"webhook_endpoints": QuotaLimits.WEBHOOK_ENDPOINTS[tier_enum],
|
|
|
|
|
},
|
|
|
|
|
"storage": {
|
|
|
|
|
"file_storage_gb": QuotaLimits.FILE_STORAGE_GB[tier_enum],
|
|
|
|
|
"report_retention_days": QuotaLimits.REPORT_RETENTION_DAYS[tier_enum],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return limits
|
|
|
|
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=404,
|
|
|
|
|
detail=f"Subscription tier '{tier}' not found"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-11-15 15:20:10 +01:00
|
|
|
@router.get("/feature-categories")
|
|
|
|
|
async def get_feature_categories():
|
|
|
|
|
"""
|
|
|
|
|
Get all feature categories with icons and translation keys
|
|
|
|
|
|
|
|
|
|
**Public endpoint** - No authentication required
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary of feature categories
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
return {
|
|
|
|
|
"categories": FeatureCategories.CATEGORIES
|
|
|
|
|
}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("failed_to_fetch_feature_categories", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=500,
|
|
|
|
|
detail="Failed to fetch feature categories"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/feature-descriptions")
|
|
|
|
|
async def get_feature_descriptions():
|
|
|
|
|
"""
|
|
|
|
|
Get user-facing feature descriptions with translation keys
|
|
|
|
|
|
|
|
|
|
**Public endpoint** - No authentication required
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary of feature descriptions mapped by feature key
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
return {
|
|
|
|
|
"features": UserFacingFeatures.FEATURE_DISPLAY
|
|
|
|
|
}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("failed_to_fetch_feature_descriptions", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=500,
|
|
|
|
|
detail="Failed to fetch feature descriptions"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-15 16:12:49 +02:00
|
|
|
@router.get("/compare")
|
|
|
|
|
async def compare_plans():
|
|
|
|
|
"""
|
|
|
|
|
Get plan comparison data for all tiers
|
|
|
|
|
|
|
|
|
|
**Public endpoint** - No authentication required
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Comparison matrix of all plans with key features and limits
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
comparison = {
|
|
|
|
|
"tiers": ["starter", "professional", "enterprise"],
|
|
|
|
|
"pricing": {},
|
|
|
|
|
"key_features": {},
|
|
|
|
|
"key_limits": {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for tier in SubscriptionTier:
|
|
|
|
|
metadata = SubscriptionPlanMetadata.PLANS[tier]
|
|
|
|
|
|
|
|
|
|
# Pricing
|
|
|
|
|
comparison["pricing"][tier.value] = {
|
|
|
|
|
"monthly": float(metadata["monthly_price"]),
|
|
|
|
|
"yearly": float(metadata["yearly_price"]),
|
|
|
|
|
"savings_percentage": round(
|
|
|
|
|
((float(metadata["monthly_price"]) * 12) - float(metadata["yearly_price"])) /
|
|
|
|
|
(float(metadata["monthly_price"]) * 12) * 100
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Key features (first 10)
|
|
|
|
|
comparison["key_features"][tier.value] = metadata["features"][:10]
|
|
|
|
|
|
|
|
|
|
# Key limits
|
|
|
|
|
comparison["key_limits"][tier.value] = {
|
|
|
|
|
"users": metadata["limits"]["users"],
|
|
|
|
|
"locations": metadata["limits"]["locations"],
|
|
|
|
|
"products": metadata["limits"]["products"],
|
|
|
|
|
"forecasts_per_day": metadata["limits"]["forecasts_per_day"],
|
|
|
|
|
"training_jobs_per_day": QuotaLimits.TRAINING_JOBS_PER_DAY[tier],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return comparison
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("failed_to_compare_plans", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=500,
|
|
|
|
|
detail="Failed to generate plan comparison"
|
|
|
|
|
)
|