From e1b3184413662ac2704fff344d4ffc7bb63b9192 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Sun, 21 Sep 2025 13:27:50 +0200 Subject: [PATCH] Add subcription level filtering --- frontend/src/App.tsx | 2 + frontend/src/api/client/apiClient.ts | 28 ++ frontend/src/api/services/subscription.ts | 115 ++----- .../auth/GlobalSubscriptionHandler.tsx | 60 ++++ .../auth/SubscriptionErrorHandler.tsx | 157 +++++++++ .../src/components/layout/Sidebar/Sidebar.tsx | 15 +- frontend/src/hooks/useSubscription.ts | 179 +++++++++++ .../src/hooks/useSubscriptionAwareRoutes.ts | 69 ++++ .../app/settings/profile/ProfilePage.tsx | 61 ++++ frontend/src/router/routes.config.ts | 9 +- gateway/app/main.py | 5 +- gateway/app/middleware/subscription.py | 298 ++++++++++++++++++ gateway/app/routes/subscription.py | 118 +++++++ gateway/app/routes/tenant.py | 16 + services/inventory/app/api/ingredients.py | 24 ++ .../app/services/inventory_service.py | 24 ++ services/tenant/app/api/subscriptions.py | 9 - services/tenant/app/main.py | 3 +- .../services/subscription_limit_service.py | 35 +- services/tenant/requirements.txt | 3 +- shared/clients/inventory_client.py | 29 +- 21 files changed, 1137 insertions(+), 122 deletions(-) create mode 100644 frontend/src/components/auth/GlobalSubscriptionHandler.tsx create mode 100644 frontend/src/components/auth/SubscriptionErrorHandler.tsx create mode 100644 frontend/src/hooks/useSubscription.ts create mode 100644 frontend/src/hooks/useSubscriptionAwareRoutes.ts create mode 100644 gateway/app/middleware/subscription.py create mode 100644 gateway/app/routes/subscription.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2b729211..a4829bb3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { AppRouter } from './router/AppRouter'; import { ThemeProvider } from './contexts/ThemeContext'; import { AuthProvider } from './contexts/AuthContext'; import { SSEProvider } from './contexts/SSEContext'; +import GlobalSubscriptionHandler from './components/auth/GlobalSubscriptionHandler'; import './i18n'; const queryClient = new QueryClient({ @@ -37,6 +38,7 @@ function App() { }> + { - try { - return await apiClient.get(`${this.baseUrl}/${tenantId}/summary`); - } catch (error) { - // Return mock data if backend endpoint doesn't exist yet - console.warn('Using mock subscription data - backend endpoint not implemented yet'); - return this.getMockUsageSummary(); - } + return apiClient.get(`${this.baseUrl}/${tenantId}/usage`); } async getAvailablePlans(): Promise { - try { - return await apiClient.get(`${this.baseUrl}/plans`); - } catch (error) { - // Return mock data if backend endpoint doesn't exist yet - console.warn('Using mock plans data - backend endpoint not implemented yet'); - return this.getMockAvailablePlans(); - } + return apiClient.get('/subscriptions/plans'); } async validatePlanUpgrade(tenantId: string, planKey: string): Promise { - try { - return await apiClient.post(`${this.baseUrl}/${tenantId}/validate-upgrade`, { - plan: planKey - }); - } catch (error) { - console.warn('Using mock validation - backend endpoint not implemented yet'); - return { can_upgrade: true }; - } + return apiClient.get(`${this.baseUrl}/${tenantId}/validate-upgrade/${planKey}`); } async upgradePlan(tenantId: string, planKey: string): Promise { - try { - return await apiClient.post(`${this.baseUrl}/${tenantId}/upgrade`, { - plan: planKey - }); - } catch (error) { - console.warn('Using mock upgrade - backend endpoint not implemented yet'); - return { success: true, message: 'Plan actualizado correctamente (modo demo)' }; - } + return apiClient.post(`${this.baseUrl}/${tenantId}/upgrade?new_plan=${planKey}`, {}); + } + + async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> { + return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-location`); + } + + async canAddProduct(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> { + return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-product`); + } + + async canAddUser(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> { + return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-user`); + } + + async hasFeature(tenantId: string, featureName: string): Promise<{ has_feature: boolean; feature_value?: any; plan?: string; reason?: string }> { + return apiClient.get(`${this.baseUrl}/${tenantId}/features/${featureName}`); } formatPrice(amount: number): string { @@ -126,69 +114,6 @@ export class SubscriptionService { }; return planInfo[planKey as keyof typeof planInfo] || { name: 'Desconocido', color: 'gray' }; } - - private getMockUsageSummary(): UsageSummary { - return { - plan: 'professional', - status: 'active', - monthly_price: 49.99, - next_billing_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - usage: { - users: { - current: 3, - limit: 10, - unlimited: false, - usage_percentage: 30 - }, - locations: { - current: 1, - limit: 3, - unlimited: false, - usage_percentage: 33 - }, - products: { - current: 45, - limit: -1, - unlimited: true, - usage_percentage: 0 - } - } - }; - } - - private getMockAvailablePlans(): AvailablePlans { - return { - plans: { - starter: { - name: 'Starter', - description: 'Perfecto para panaderías pequeñas', - monthly_price: 29.99, - max_users: 3, - max_locations: 1, - max_products: 50, - popular: false - }, - professional: { - name: 'Professional', - description: 'Para panaderías en crecimiento', - monthly_price: 49.99, - max_users: 10, - max_locations: 3, - max_products: -1, - popular: true - }, - enterprise: { - name: 'Enterprise', - description: 'Para grandes operaciones', - monthly_price: 99.99, - max_users: -1, - max_locations: -1, - max_products: -1, - contact_sales: true - } - } - }; - } } export const subscriptionService = new SubscriptionService(); \ No newline at end of file diff --git a/frontend/src/components/auth/GlobalSubscriptionHandler.tsx b/frontend/src/components/auth/GlobalSubscriptionHandler.tsx new file mode 100644 index 00000000..560ad043 --- /dev/null +++ b/frontend/src/components/auth/GlobalSubscriptionHandler.tsx @@ -0,0 +1,60 @@ +/** + * GlobalSubscriptionHandler - Listens for subscription errors and shows upgrade dialogs + */ + +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { subscriptionErrorEmitter, SubscriptionError } from '../../api/client/apiClient'; +import SubscriptionErrorHandler from './SubscriptionErrorHandler'; + +const GlobalSubscriptionHandler: React.FC = () => { + const [subscriptionError, setSubscriptionError] = useState(null); + const [showErrorDialog, setShowErrorDialog] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + const handleSubscriptionError = (event: CustomEvent) => { + setSubscriptionError(event.detail); + setShowErrorDialog(true); + }; + + subscriptionErrorEmitter.addEventListener( + 'subscriptionError', + handleSubscriptionError as EventListener + ); + + return () => { + subscriptionErrorEmitter.removeEventListener( + 'subscriptionError', + handleSubscriptionError as EventListener + ); + }; + }, []); + + const handleClose = () => { + setShowErrorDialog(false); + setSubscriptionError(null); + }; + + const handleUpgrade = () => { + setShowErrorDialog(false); + setSubscriptionError(null); + navigate('/app/settings/profile'); + }; + + if (!subscriptionError) { + return null; + } + + return ( + + ); +}; + +export default GlobalSubscriptionHandler; +export { GlobalSubscriptionHandler }; \ No newline at end of file diff --git a/frontend/src/components/auth/SubscriptionErrorHandler.tsx b/frontend/src/components/auth/SubscriptionErrorHandler.tsx new file mode 100644 index 00000000..971778d4 --- /dev/null +++ b/frontend/src/components/auth/SubscriptionErrorHandler.tsx @@ -0,0 +1,157 @@ +/** + * SubscriptionErrorHandler - Handles subscription-related API errors + */ + +import React from 'react'; +import { Modal, Button, Card } from '../ui'; +import { Crown, Lock, ArrowRight, AlertTriangle } from 'lucide-react'; + +interface SubscriptionError { + error: string; + message: string; + code: string; + details: { + required_feature: string; + required_level: string; + current_plan: string; + upgrade_url: string; + }; +} + +interface SubscriptionErrorHandlerProps { + error: SubscriptionError; + isOpen: boolean; + onClose: () => void; + onUpgrade: () => void; +} + +const SubscriptionErrorHandler: React.FC = ({ + error, + isOpen, + onClose, + onUpgrade +}) => { + const getFeatureDisplayName = (feature: string) => { + const featureNames: Record = { + analytics: 'Analytics', + forecasting: 'Pronósticos', + ai_insights: 'Insights de IA', + production_optimization: 'Optimización de Producción', + multi_location: 'Multi-ubicación' + }; + return featureNames[feature] || feature; + }; + + const getLevelDisplayName = (level: string) => { + const levelNames: Record = { + basic: 'Básico', + advanced: 'Avanzado', + predictive: 'Predictivo' + }; + return levelNames[level] || level; + }; + + const getRequiredPlan = (level: string) => { + switch (level) { + case 'advanced': + return 'Professional'; + case 'predictive': + return 'Enterprise'; + default: + return 'Professional'; + } + }; + + const getPlanColor = (plan: string) => { + switch (plan.toLowerCase()) { + case 'professional': + return 'bg-gradient-to-br from-purple-500 to-indigo-600'; + case 'enterprise': + return 'bg-gradient-to-br from-yellow-400 to-orange-500'; + default: + return 'bg-gradient-to-br from-blue-500 to-cyan-600'; + } + }; + + const requiredPlan = getRequiredPlan(error.details.required_level); + const featureName = getFeatureDisplayName(error.details.required_feature); + const levelName = getLevelDisplayName(error.details.required_level); + + return ( + +
+
+
+ +
+

+ Actualización de Plan Requerida +

+

+ Esta funcionalidad requiere un plan {requiredPlan} o superior +

+
+ + +
+
+ Funcionalidad: + {featureName} +
+
+ Nivel requerido: + {levelName} +
+
+ Plan actual: + {error.details.current_plan} +
+
+ Plan requerido: + {requiredPlan} +
+
+
+ +
+
+ +
+

+ Acceso Restringido +

+

+ {error.message} +

+
+
+
+ +
+ + + +
+ +
+ + Funcionalidad protegida por suscripción +
+
+
+ ); +}; + +export default SubscriptionErrorHandler; \ No newline at end of file diff --git a/frontend/src/components/layout/Sidebar/Sidebar.tsx b/frontend/src/components/layout/Sidebar/Sidebar.tsx index ba8781cb..71755622 100644 --- a/frontend/src/components/layout/Sidebar/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar/Sidebar.tsx @@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useAuthUser, useIsAuthenticated } from '../../../stores'; import { useCurrentTenantAccess } from '../../../stores/tenant.store'; import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config'; +import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes'; import { Button } from '../../ui'; import { Badge } from '../../ui'; import { Tooltip } from '../../ui'; @@ -136,11 +137,13 @@ export const Sidebar = forwardRef(({ const [hoveredItem, setHoveredItem] = useState(null); const sidebarRef = React.useRef(null); - // Get navigation routes from config and convert to navigation items - memoized + // Get subscription-aware navigation routes + const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []); + const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes); + + // Convert routes to navigation items - memoized const navigationItems = useMemo(() => { - const navigationRoutes = getNavigationRoutes(); - - const convertRoutesToItems = (routes: typeof navigationRoutes): NavigationItem[] => { + const convertRoutesToItems = (routes: typeof subscriptionFilteredRoutes): NavigationItem[] => { return routes.map(route => ({ id: route.path, label: route.title, @@ -152,8 +155,8 @@ export const Sidebar = forwardRef(({ })); }; - return customItems || convertRoutesToItems(navigationRoutes); - }, [customItems]); + return customItems || convertRoutesToItems(subscriptionFilteredRoutes); + }, [customItems, subscriptionFilteredRoutes]); // Filter items based on user permissions - memoized to prevent infinite re-renders const visibleItems = useMemo(() => { diff --git a/frontend/src/hooks/useSubscription.ts b/frontend/src/hooks/useSubscription.ts new file mode 100644 index 00000000..c9516f45 --- /dev/null +++ b/frontend/src/hooks/useSubscription.ts @@ -0,0 +1,179 @@ +/** + * Subscription hook for checking plan features and limits + */ + +import { useState, useEffect, useCallback } from 'react'; +import { subscriptionService } from '../api'; +import { useCurrentTenant } from '../stores'; +import { useAuthUser } from '../stores/auth.store'; + +export interface SubscriptionFeature { + hasFeature: boolean; + featureLevel?: string; + reason?: string; +} + +export interface SubscriptionLimits { + canAddUser: boolean; + canAddLocation: boolean; + canAddProduct: boolean; + usageData?: any; +} + +export interface SubscriptionInfo { + plan: string; + status: 'active' | 'inactive' | 'past_due' | 'cancelled'; + features: Record; + loading: boolean; + error?: string; +} + +export const useSubscription = () => { + const [subscriptionInfo, setSubscriptionInfo] = useState({ + plan: 'starter', + status: 'active', + features: {}, + loading: true, + }); + + const currentTenant = useCurrentTenant(); + const user = useAuthUser(); + const tenantId = currentTenant?.id || user?.tenant_id; + + // Load subscription data + const loadSubscriptionData = useCallback(async () => { + if (!tenantId) { + setSubscriptionInfo(prev => ({ ...prev, loading: false, error: 'No tenant ID available' })); + return; + } + + try { + setSubscriptionInfo(prev => ({ ...prev, loading: true, error: undefined })); + + const usageSummary = await subscriptionService.getUsageSummary(tenantId); + + setSubscriptionInfo({ + plan: usageSummary.plan, + status: usageSummary.status, + features: usageSummary.usage || {}, + loading: false, + }); + } catch (error) { + console.error('Error loading subscription data:', error); + setSubscriptionInfo(prev => ({ + ...prev, + loading: false, + error: 'Failed to load subscription data' + })); + } + }, [tenantId]); + + useEffect(() => { + loadSubscriptionData(); + }, [loadSubscriptionData]); + + // Check if user has a specific feature + const hasFeature = useCallback(async (featureName: string): Promise => { + if (!tenantId) { + return { hasFeature: false, reason: 'No tenant ID available' }; + } + + try { + const result = await subscriptionService.hasFeature(tenantId, featureName); + return { + hasFeature: result.has_feature, + featureLevel: result.feature_value, + reason: result.reason + }; + } catch (error) { + console.error('Error checking feature:', error); + return { hasFeature: false, reason: 'Error checking feature access' }; + } + }, [tenantId]); + + // Check analytics access level + const getAnalyticsAccess = useCallback((): { hasAccess: boolean; level: string; reason?: string } => { + const { plan } = subscriptionInfo; + + switch (plan) { + case 'starter': + return { hasAccess: true, level: 'basic' }; + case 'professional': + return { hasAccess: true, level: 'advanced' }; + case 'enterprise': + return { hasAccess: true, level: 'predictive' }; + default: + return { hasAccess: false, level: 'none', reason: 'No valid subscription plan' }; + } + }, [subscriptionInfo.plan]); + + // Check if user can access specific analytics features + const canAccessAnalytics = useCallback((requiredLevel: 'basic' | 'advanced' | 'predictive' = 'basic'): boolean => { + const { hasAccess, level } = getAnalyticsAccess(); + + if (!hasAccess) return false; + + const levelHierarchy = { + 'basic': 1, + 'advanced': 2, + 'predictive': 3 + }; + + return levelHierarchy[level as keyof typeof levelHierarchy] >= levelHierarchy[requiredLevel]; + }, [getAnalyticsAccess]); + + // Check if user can access forecasting features + const canAccessForecasting = useCallback((): boolean => { + return canAccessAnalytics('advanced'); // Forecasting requires advanced or higher + }, [canAccessAnalytics]); + + // Check if user can access AI insights + const canAccessAIInsights = useCallback((): boolean => { + return canAccessAnalytics('predictive'); // AI Insights requires enterprise plan + }, [canAccessAnalytics]); + + // Check usage limits + const checkLimits = useCallback(async (): Promise => { + if (!tenantId) { + return { + canAddUser: false, + canAddLocation: false, + canAddProduct: false + }; + } + + try { + const [userCheck, locationCheck, productCheck] = await Promise.all([ + subscriptionService.canAddUser(tenantId), + subscriptionService.canAddLocation(tenantId), + subscriptionService.canAddProduct(tenantId) + ]); + + return { + canAddUser: userCheck.can_add, + canAddLocation: locationCheck.can_add, + canAddProduct: productCheck.can_add, + }; + } catch (error) { + console.error('Error checking limits:', error); + return { + canAddUser: false, + canAddLocation: false, + canAddProduct: false + }; + } + }, [tenantId]); + + return { + subscriptionInfo, + hasFeature, + getAnalyticsAccess, + canAccessAnalytics, + canAccessForecasting, + canAccessAIInsights, + checkLimits, + refreshSubscription: loadSubscriptionData, + }; +}; + +export default useSubscription; \ No newline at end of file diff --git a/frontend/src/hooks/useSubscriptionAwareRoutes.ts b/frontend/src/hooks/useSubscriptionAwareRoutes.ts new file mode 100644 index 00000000..bd9656f3 --- /dev/null +++ b/frontend/src/hooks/useSubscriptionAwareRoutes.ts @@ -0,0 +1,69 @@ +/** + * Hook for filtering routes based on subscription features + */ + +import { useMemo } from 'react'; +import { RouteConfig } from '../router/routes.config'; +import { useSubscription } from './useSubscription'; + +export const useSubscriptionAwareRoutes = (routes: RouteConfig[]) => { + const { subscriptionInfo, canAccessAnalytics } = useSubscription(); + + const filteredRoutes = useMemo(() => { + const filterRoutesBySubscription = (routeList: RouteConfig[]): RouteConfig[] => { + return routeList.reduce((filtered, route) => { + // Check if route requires subscription features + if (route.requiredAnalyticsLevel) { + const hasAccess = canAccessAnalytics(route.requiredAnalyticsLevel); + if (!hasAccess) { + return filtered; // Skip this route + } + } + + // Handle specific analytics routes + if (route.path === '/app/analytics') { + // Only show analytics if user has at least basic access + if (!canAccessAnalytics('basic')) { + return filtered; // Skip analytics entirely + } + } + + // Filter children recursively + const filteredRoute = { + ...route, + children: route.children ? filterRoutesBySubscription(route.children) : route.children + }; + + // Only include parent if it has accessible children or is accessible itself + if (route.children) { + if (filteredRoute.children && filteredRoute.children.length > 0) { + filtered.push(filteredRoute); + } else if (!route.requiredAnalyticsLevel) { + // Include parent without children if it doesn't require subscription + filtered.push({ ...route, children: [] }); + } + } else { + filtered.push(filteredRoute); + } + + return filtered; + }, [] as RouteConfig[]); + }; + + if (subscriptionInfo.loading) { + // While loading, show basic routes only + return routes.filter(route => + !route.requiredAnalyticsLevel && + route.path !== '/app/analytics' + ); + } + + return filterRoutesBySubscription(routes); + }, [routes, subscriptionInfo, canAccessAnalytics]); + + return { + filteredRoutes, + subscriptionInfo, + isLoading: subscriptionInfo.loading + }; +}; \ No newline at end of file diff --git a/frontend/src/pages/app/settings/profile/ProfilePage.tsx b/frontend/src/pages/app/settings/profile/ProfilePage.tsx index cfb8a35e..7e022191 100644 --- a/frontend/src/pages/app/settings/profile/ProfilePage.tsx +++ b/frontend/src/pages/app/settings/profile/ProfilePage.tsx @@ -1209,6 +1209,67 @@ const ProfilePage: React.FC = () => { + {/* Features Section */} +
+
+ + Funcionalidades Incluidas +
+
+ {(() => { + const getPlanFeatures = (planKey: string) => { + switch (planKey) { + case 'starter': + return [ + '✓ Panel de Control Básico', + '✓ Gestión de Inventario', + '✓ Gestión de Pedidos', + '✓ Gestión de Proveedores', + '✓ Punto de Venta Básico', + '✗ Analytics Avanzados', + '✗ Pronósticos IA', + '✗ Insights Predictivos' + ]; + case 'professional': + return [ + '✓ Panel de Control Avanzado', + '✓ Gestión de Inventario Completa', + '✓ Analytics de Ventas', + '✓ Pronósticos con IA (92% precisión)', + '✓ Análisis de Rendimiento', + '✓ Optimización de Producción', + '✓ Integración POS', + '✗ Insights Predictivos Avanzados' + ]; + case 'enterprise': + return [ + '✓ Todas las funcionalidades Professional', + '✓ Insights Predictivos con IA', + '✓ Analytics Multi-ubicación', + '✓ Integración ERP', + '✓ API Personalizada', + '✓ Gestor de Cuenta Dedicado', + '✓ Soporte 24/7 Prioritario', + '✓ Demo Personalizada' + ]; + default: + return []; + } + }; + + return getPlanFeatures(planKey).map((feature, index) => ( +
+ {feature} +
+ )); + })()} +
+
+ {isCurrentPlan ? ( diff --git a/frontend/src/router/routes.config.ts b/frontend/src/router/routes.config.ts index ffd2c66c..99ac08fe 100644 --- a/frontend/src/router/routes.config.ts +++ b/frontend/src/router/routes.config.ts @@ -14,6 +14,8 @@ export interface RouteConfig { requiresAuth: boolean; requiredRoles?: string[]; requiredPermissions?: string[]; + requiredSubscriptionFeature?: string; + requiredAnalyticsLevel?: 'basic' | 'advanced' | 'predictive'; showInNavigation?: boolean; showInBreadcrumbs?: boolean; children?: RouteConfig[]; @@ -343,7 +345,7 @@ export const routesConfig: RouteConfig[] = [ ], }, - // Analytics Section + // Analytics Section - Subscription protected { path: '/app/analytics', name: 'Analytics', @@ -352,6 +354,7 @@ export const routesConfig: RouteConfig[] = [ icon: 'sales', requiresAuth: true, requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS, + requiredAnalyticsLevel: 'basic', showInNavigation: true, children: [ { @@ -362,6 +365,7 @@ export const routesConfig: RouteConfig[] = [ icon: 'forecasting', requiresAuth: true, requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS, + requiredAnalyticsLevel: 'advanced', showInNavigation: true, showInBreadcrumbs: true, }, @@ -373,6 +377,7 @@ export const routesConfig: RouteConfig[] = [ icon: 'sales', requiresAuth: true, requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS, + requiredAnalyticsLevel: 'advanced', showInNavigation: true, showInBreadcrumbs: true, }, @@ -384,6 +389,7 @@ export const routesConfig: RouteConfig[] = [ icon: 'sales', requiresAuth: true, requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS, + requiredAnalyticsLevel: 'advanced', showInNavigation: true, showInBreadcrumbs: true, }, @@ -395,6 +401,7 @@ export const routesConfig: RouteConfig[] = [ icon: 'forecasting', requiresAuth: true, requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS, + requiredAnalyticsLevel: 'predictive', showInNavigation: true, showInBreadcrumbs: true, }, diff --git a/gateway/app/main.py b/gateway/app/main.py index eb7a7e78..197b1a6a 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -18,7 +18,8 @@ from app.core.service_discovery import ServiceDiscovery from app.middleware.auth import AuthMiddleware from app.middleware.logging import LoggingMiddleware from app.middleware.rate_limit import RateLimitMiddleware -from app.routes import auth, tenant, notification, nominatim, user +from app.middleware.subscription import SubscriptionMiddleware +from app.routes import auth, tenant, notification, nominatim, user, subscription from shared.monitoring.logging import setup_logging from shared.monitoring.metrics import MetricsCollector @@ -56,12 +57,14 @@ app.add_middleware( # Custom middleware - Add in correct order (outer to inner) app.add_middleware(LoggingMiddleware) app.add_middleware(RateLimitMiddleware, calls_per_minute=300) +app.add_middleware(SubscriptionMiddleware, tenant_service_url=settings.TENANT_SERVICE_URL) app.add_middleware(AuthMiddleware) # Include routers app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"]) app.include_router(user.router, prefix="/api/v1/users", tags=["users"]) app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"]) +app.include_router(subscription.router, prefix="/api/v1", tags=["subscriptions"]) app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"]) app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"]) diff --git a/gateway/app/middleware/subscription.py b/gateway/app/middleware/subscription.py new file mode 100644 index 00000000..c29ba73e --- /dev/null +++ b/gateway/app/middleware/subscription.py @@ -0,0 +1,298 @@ +""" +Subscription Middleware - Enforces subscription limits and feature access +""" + +import re +import json +import structlog +from fastapi import Request, Response, HTTPException +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +import httpx +from typing import Dict, Any, Optional +import asyncio + +from app.core.config import settings + +logger = structlog.get_logger() + + +class SubscriptionMiddleware(BaseHTTPMiddleware): + """Middleware to enforce subscription-based access control""" + + def __init__(self, app, tenant_service_url: str): + super().__init__(app) + self.tenant_service_url = tenant_service_url.rstrip('/') + + # Define route patterns that require subscription validation + self.protected_routes = { + # Analytics routes - require different levels based on actual app routes + r'/api/v1/tenants/[^/]+/analytics/.*': { + 'feature': 'analytics', + 'minimum_level': 'advanced' # General analytics require Professional+ + }, + r'/api/v1/tenants/[^/]+/forecasts/.*': { + 'feature': 'analytics', + 'minimum_level': 'advanced' # Forecasting requires Professional+ + }, + r'/api/v1/tenants/[^/]+/predictions/.*': { + 'feature': 'analytics', + 'minimum_level': 'advanced' # Predictions require Professional+ + }, + # Training and AI models - Professional+ + r'/api/v1/tenants/[^/]+/training/.*': { + 'feature': 'analytics', + 'minimum_level': 'advanced' + }, + r'/api/v1/tenants/[^/]+/models/.*': { + 'feature': 'analytics', + 'minimum_level': 'advanced' + }, + # Advanced production features - Professional+ + r'/api/v1/tenants/[^/]+/production/optimization/.*': { + 'feature': 'analytics', + 'minimum_level': 'advanced' + }, + # Enterprise-only features + r'/api/v1/tenants/[^/]+/statistics.*': { + 'feature': 'analytics', + 'minimum_level': 'predictive' # Advanced stats for Enterprise only + } + } + + async def dispatch(self, request: Request, call_next): + """Process the request and check subscription requirements""" + + # Skip subscription check for certain routes + if self._should_skip_subscription_check(request): + return await call_next(request) + + # Check if route requires subscription validation + subscription_requirement = self._get_subscription_requirement(request.url.path) + if not subscription_requirement: + return await call_next(request) + + # Get tenant ID from request + tenant_id = self._extract_tenant_id(request) + if not tenant_id: + return JSONResponse( + status_code=400, + content={ + "error": "subscription_validation_failed", + "message": "Tenant ID required for subscription validation", + "code": "MISSING_TENANT_ID" + } + ) + + # Validate subscription + validation_result = await self._validate_subscription( + request, + tenant_id, + subscription_requirement['feature'], + subscription_requirement['minimum_level'] + ) + + if not validation_result['allowed']: + return JSONResponse( + status_code=403, + content={ + "error": "subscription_required", + "message": validation_result['message'], + "code": "SUBSCRIPTION_UPGRADE_REQUIRED", + "details": { + "required_feature": subscription_requirement['feature'], + "required_level": subscription_requirement['minimum_level'], + "current_plan": validation_result.get('current_plan', 'unknown'), + "upgrade_url": "/app/settings/profile" + } + } + ) + + # Subscription validation passed, continue with request + response = await call_next(request) + return response + + def _should_skip_subscription_check(self, request: Request) -> bool: + """Check if subscription validation should be skipped""" + path = request.url.path + method = request.method + + # Skip for health checks, auth, and public routes + skip_patterns = [ + r'/health.*', + r'/metrics.*', + r'/api/v1/auth/.*', + r'/api/v1/subscriptions/.*', # Subscription management itself + r'/api/v1/tenants/[^/]+/members.*', # Basic tenant info + r'/docs.*', + r'/openapi\.json' + ] + + # Skip OPTIONS requests (CORS preflight) + if method == "OPTIONS": + return True + + for pattern in skip_patterns: + if re.match(pattern, path): + return True + + return False + + def _get_subscription_requirement(self, path: str) -> Optional[Dict[str, str]]: + """Get subscription requirement for a given path""" + for pattern, requirement in self.protected_routes.items(): + if re.match(pattern, path): + return requirement + return None + + def _extract_tenant_id(self, request: Request) -> Optional[str]: + """Extract tenant ID from request""" + # Try to get from URL path first + path_match = re.search(r'/api/v1/tenants/([^/]+)/', request.url.path) + if path_match: + return path_match.group(1) + + # Try to get from headers + tenant_id = request.headers.get('x-tenant-id') + if tenant_id: + return tenant_id + + # Try to get from user state (set by auth middleware) + if hasattr(request.state, 'user') and request.state.user: + return request.state.user.get('tenant_id') + + return None + + async def _validate_subscription( + self, + request: Request, + tenant_id: str, + feature: str, + minimum_level: str + ) -> Dict[str, Any]: + """Validate subscription feature access using the same pattern as other gateway services""" + try: + # Use the same authentication pattern as gateway routes + headers = dict(request.headers) + headers.pop("host", None) + + # Add user context headers if available (same as _proxy_request) + if hasattr(request.state, 'user') and request.state.user: + user = request.state.user + headers["x-user-id"] = str(user.get('user_id', '')) + headers["x-user-email"] = str(user.get('email', '')) + headers["x-user-role"] = str(user.get('role', 'user')) + headers["x-user-full-name"] = str(user.get('full_name', '')) + headers["x-tenant-id"] = str(user.get('tenant_id', '')) + + # Call tenant service to check subscription with gateway-appropriate timeout + timeout_config = httpx.Timeout( + connect=2.0, # Connection timeout - short for gateway + read=10.0, # Read timeout + write=2.0, # Write timeout + pool=2.0 # Pool timeout + ) + + async with httpx.AsyncClient(timeout=timeout_config) as client: + # Check feature access + feature_response = await client.get( + f"{settings.TENANT_SERVICE_URL}/api/v1/subscriptions/{tenant_id}/features/{feature}", + headers=headers + ) + + if feature_response.status_code != 200: + logger.warning( + "Failed to check feature access", + tenant_id=tenant_id, + feature=feature, + status_code=feature_response.status_code, + response_text=feature_response.text, + url=f"{settings.TENANT_SERVICE_URL}/api/v1/subscriptions/{tenant_id}/features/{feature}" + ) + # Fail open for availability (let service handle detailed check if needed) + return { + 'allowed': True, + 'message': 'Access granted (validation service unavailable)', + 'current_plan': 'unknown' + } + + feature_data = feature_response.json() + logger.info("Feature check response", + tenant_id=tenant_id, + feature=feature, + response=feature_data) + + if not feature_data.get('has_feature'): + return { + 'allowed': False, + 'message': f'Feature "{feature}" not available in your current plan', + 'current_plan': feature_data.get('plan', 'unknown') + } + + # Check feature level if it's analytics + if feature == 'analytics': + feature_level = feature_data.get('feature_value', 'basic') + if not self._check_analytics_level(feature_level, minimum_level): + return { + 'allowed': False, + 'message': f'Analytics level "{minimum_level}" required. Current level: "{feature_level}"', + 'current_plan': feature_data.get('plan', 'unknown') + } + + return { + 'allowed': True, + 'message': 'Access granted', + 'current_plan': feature_data.get('plan', 'unknown') + } + + except asyncio.TimeoutError: + logger.error( + "Timeout validating subscription", + tenant_id=tenant_id, + feature=feature + ) + # Fail open for availability (let service handle detailed check) + return { + 'allowed': True, + 'message': 'Access granted (validation timeout)', + 'current_plan': 'unknown' + } + except httpx.RequestError as e: + logger.error( + "Request error validating subscription", + tenant_id=tenant_id, + feature=feature, + error=str(e) + ) + # Fail open for availability + return { + 'allowed': True, + 'message': 'Access granted (validation service unavailable)', + 'current_plan': 'unknown' + } + except Exception as e: + logger.error( + "Subscription validation error", + tenant_id=tenant_id, + feature=feature, + error=str(e) + ) + # Fail open for availability (let service handle detailed check) + return { + 'allowed': True, + 'message': 'Access granted (validation error)', + 'current_plan': 'unknown' + } + + def _check_analytics_level(self, current_level: str, required_level: str) -> bool: + """Check if current analytics level meets the requirement""" + level_hierarchy = { + 'basic': 1, + 'advanced': 2, + 'predictive': 3 + } + + current_rank = level_hierarchy.get(current_level, 0) + required_rank = level_hierarchy.get(required_level, 0) + + return current_rank >= required_rank \ No newline at end of file diff --git a/gateway/app/routes/subscription.py b/gateway/app/routes/subscription.py new file mode 100644 index 00000000..2644e1fd --- /dev/null +++ b/gateway/app/routes/subscription.py @@ -0,0 +1,118 @@ +""" +Subscription routes for API Gateway - Direct subscription endpoints +""" + +from fastapi import APIRouter, Request, Response, HTTPException, Path +from fastapi.responses import JSONResponse +import httpx +import logging +from typing import Optional + +from app.core.config import settings + +logger = logging.getLogger(__name__) +router = APIRouter() + +# ================================================================ +# SUBSCRIPTION ENDPOINTS - Direct routing to tenant service +# ================================================================ + +@router.api_route("/subscriptions/{tenant_id}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) +async def proxy_subscription_endpoints(request: Request, tenant_id: str = Path(...), path: str = ""): + """Proxy subscription requests directly to tenant service""" + target_path = f"/api/v1/subscriptions/{tenant_id}/{path}".rstrip("/") + return await _proxy_to_tenant_service(request, target_path) + +@router.api_route("/subscriptions/plans", methods=["GET", "OPTIONS"]) +async def proxy_subscription_plans(request: Request): + """Proxy subscription plans request to tenant service""" + target_path = "/api/v1/plans/available" + return await _proxy_to_tenant_service(request, target_path) + +# ================================================================ +# PROXY HELPER FUNCTIONS +# ================================================================ + +async def _proxy_to_tenant_service(request: Request, target_path: str): + """Proxy request to tenant service""" + return await _proxy_request(request, target_path, settings.TENANT_SERVICE_URL) + +async def _proxy_request(request: Request, target_path: str, service_url: str): + """Generic proxy function with enhanced error handling""" + + # Handle OPTIONS requests directly for CORS + if request.method == "OPTIONS": + return Response( + status_code=200, + headers={ + "Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST, + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "86400" + } + ) + + try: + url = f"{service_url}{target_path}" + + # Forward headers and add user/tenant context + headers = dict(request.headers) + headers.pop("host", None) + + # Add user context headers if available + if hasattr(request.state, 'user') and request.state.user: + user = request.state.user + headers["x-user-id"] = str(user.get('user_id', '')) + headers["x-user-email"] = str(user.get('email', '')) + headers["x-user-role"] = str(user.get('role', 'user')) + headers["x-user-full-name"] = str(user.get('full_name', '')) + headers["x-tenant-id"] = str(user.get('tenant_id', '')) + logger.info(f"Forwarding subscription request to {url} with user context: user_id={user.get('user_id')}, email={user.get('email')}") + else: + logger.warning(f"No user context available when forwarding subscription request to {url}") + + # Get request body if present + body = None + if request.method in ["POST", "PUT", "PATCH"]: + body = await request.body() + + # Add query parameters + params = dict(request.query_params) + + timeout_config = httpx.Timeout( + connect=30.0, + read=60.0, + write=30.0, + pool=30.0 + ) + + async with httpx.AsyncClient(timeout=timeout_config) as client: + response = await client.request( + method=request.method, + url=url, + headers=headers, + content=body, + params=params + ) + + # Handle different response types + if response.headers.get("content-type", "").startswith("application/json"): + try: + content = response.json() + except: + content = {"message": "Invalid JSON response from service"} + else: + content = response.text + + return JSONResponse( + status_code=response.status_code, + content=content + ) + + except Exception as e: + logger.error(f"Unexpected error proxying subscription request to {service_url}{target_path}: {e}") + raise HTTPException( + status_code=500, + detail="Internal gateway error" + ) \ No newline at end of file diff --git a/gateway/app/routes/tenant.py b/gateway/app/routes/tenant.py index 89fd6472..ef17d80c 100644 --- a/gateway/app/routes/tenant.py +++ b/gateway/app/routes/tenant.py @@ -53,6 +53,22 @@ async def delete_user_tenants(request: Request, user_id: str = Path(...)): """Get all tenant memberships for a user (admin only)""" return await _proxy_to_tenant_service(request, f"/api/v1/tenants/user/{user_id}/memberships") +# ================================================================ +# TENANT SUBSCRIPTION ENDPOINTS +# ================================================================ + +@router.api_route("/{tenant_id}/subscriptions/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) +async def proxy_tenant_subscriptions(request: Request, tenant_id: str = Path(...), path: str = ""): + """Proxy tenant subscription requests to tenant service""" + target_path = f"/api/v1/subscriptions/{tenant_id}/{path}".rstrip("/") + return await _proxy_to_tenant_service(request, target_path) + +@router.api_route("/subscriptions/plans", methods=["GET", "OPTIONS"]) +async def proxy_available_plans(request: Request): + """Proxy available plans request to tenant service""" + target_path = "/api/v1/plans/available" + return await _proxy_to_tenant_service(request, target_path) + # ================================================================ # TENANT-SCOPED DATA SERVICE ENDPOINTS # ================================================================ diff --git a/services/inventory/app/api/ingredients.py b/services/inventory/app/api/ingredients.py index 245a6b93..8a1852ad 100644 --- a/services/inventory/app/api/ingredients.py +++ b/services/inventory/app/api/ingredients.py @@ -69,6 +69,30 @@ async def create_ingredient( ) +@router.get("/tenants/{tenant_id}/ingredients/count") +async def count_ingredients( + tenant_id: UUID = Path(...), + current_user: dict = Depends(get_current_user_dep), + db: AsyncSession = Depends(get_db) +) -> dict: + """Get count of ingredients for a tenant""" + + try: + service = InventoryService() + count = await service.count_ingredients_by_tenant(tenant_id) + + return { + "tenant_id": str(tenant_id), + "ingredient_count": count + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to count ingredients: {str(e)}" + ) + + @router.get("/tenants/{tenant_id}/ingredients/{ingredient_id}", response_model=IngredientResponse) async def get_ingredient( ingredient_id: UUID, diff --git a/services/inventory/app/services/inventory_service.py b/services/inventory/app/services/inventory_service.py index da5e07e5..d9956a16 100644 --- a/services/inventory/app/services/inventory_service.py +++ b/services/inventory/app/services/inventory_service.py @@ -219,6 +219,30 @@ class InventoryService: logger.error("Failed to get ingredients", error=str(e), tenant_id=tenant_id) raise + async def count_ingredients_by_tenant(self, tenant_id: UUID) -> int: + """Count total number of active ingredients for a tenant""" + try: + async with get_db_transaction() as db: + # Use SQLAlchemy count query for efficiency + from sqlalchemy import select, func, and_ + + query = select(func.count(Ingredient.id)).where( + and_( + Ingredient.tenant_id == tenant_id, + Ingredient.is_active == True + ) + ) + + result = await db.execute(query) + count = result.scalar() or 0 + + logger.info("Counted ingredients", tenant_id=tenant_id, count=count) + return count + + except Exception as e: + logger.error("Failed to count ingredients", error=str(e), tenant_id=tenant_id) + raise + # ===== STOCK MANAGEMENT ===== async def add_stock( diff --git a/services/tenant/app/api/subscriptions.py b/services/tenant/app/api/subscriptions.py index 3b3dbe14..5fba3655 100644 --- a/services/tenant/app/api/subscriptions.py +++ b/services/tenant/app/api/subscriptions.py @@ -39,7 +39,6 @@ def get_subscription_repository(): raise HTTPException(status_code=500, detail="Repository initialization failed") @router.get("/subscriptions/{tenant_id}/limits") -@track_endpoint_metrics("subscription_get_limits") async def get_subscription_limits( tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), @@ -62,7 +61,6 @@ async def get_subscription_limits( ) @router.get("/subscriptions/{tenant_id}/usage") -@track_endpoint_metrics("subscription_get_usage") async def get_usage_summary( tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), @@ -85,7 +83,6 @@ async def get_usage_summary( ) @router.get("/subscriptions/{tenant_id}/can-add-location") -@track_endpoint_metrics("subscription_check_location_limit") async def can_add_location( tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), @@ -108,7 +105,6 @@ async def can_add_location( ) @router.get("/subscriptions/{tenant_id}/can-add-product") -@track_endpoint_metrics("subscription_check_product_limit") async def can_add_product( tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), @@ -131,7 +127,6 @@ async def can_add_product( ) @router.get("/subscriptions/{tenant_id}/can-add-user") -@track_endpoint_metrics("subscription_check_user_limit") async def can_add_user( tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), @@ -154,7 +149,6 @@ async def can_add_user( ) @router.get("/subscriptions/{tenant_id}/features/{feature}") -@track_endpoint_metrics("subscription_check_feature") async def has_feature( tenant_id: UUID = Path(..., description="Tenant ID"), feature: str = Path(..., description="Feature name"), @@ -179,7 +173,6 @@ async def has_feature( ) @router.get("/subscriptions/{tenant_id}/validate-upgrade/{new_plan}") -@track_endpoint_metrics("subscription_validate_upgrade") async def validate_plan_upgrade( tenant_id: UUID = Path(..., description="Tenant ID"), new_plan: str = Path(..., description="New plan name"), @@ -204,7 +197,6 @@ async def validate_plan_upgrade( ) @router.post("/subscriptions/{tenant_id}/upgrade") -@track_endpoint_metrics("subscription_upgrade_plan") async def upgrade_subscription_plan( tenant_id: UUID = Path(..., description="Tenant ID"), new_plan: str = Query(..., description="New plan name"), @@ -250,7 +242,6 @@ async def upgrade_subscription_plan( ) @router.get("/plans/available") -@track_endpoint_metrics("subscription_get_available_plans") async def get_available_plans(): """Get all available subscription plans with features and pricing""" diff --git a/services/tenant/app/main.py b/services/tenant/app/main.py index 5c69dced..e3214f46 100644 --- a/services/tenant/app/main.py +++ b/services/tenant/app/main.py @@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings from app.core.database import database_manager -from app.api import tenants +from app.api import tenants, subscriptions from shared.monitoring.logging import setup_logging from shared.monitoring.metrics import MetricsCollector @@ -41,6 +41,7 @@ app.add_middleware( # Include routers app.include_router(tenants.router, prefix="/api/v1", tags=["tenants"]) +app.include_router(subscriptions.router, prefix="/api/v1", tags=["subscriptions"]) @app.on_event("startup") async def startup_event(): diff --git a/services/tenant/app/services/subscription_limit_service.py b/services/tenant/app/services/subscription_limit_service.py index f4921036..1d43fc1d 100644 --- a/services/tenant/app/services/subscription_limit_service.py +++ b/services/tenant/app/services/subscription_limit_service.py @@ -7,6 +7,7 @@ import structlog from typing import Dict, Any, Optional from sqlalchemy.ext.asyncio import AsyncSession from fastapi import HTTPException, status +import httpx from app.repositories import SubscriptionRepository, TenantRepository, TenantMemberRepository from app.models.tenants import Subscription, Tenant, TenantMember @@ -287,10 +288,12 @@ class SubscriptionLimitService: # Get current usage members = await self.member_repo.get_tenant_members(tenant_id, active_only=True) current_users = len(members) - - # TODO: Implement actual location and product counts + + # Get actual ingredient/product count from inventory service + current_products = await self._get_ingredient_count(tenant_id) + + # TODO: Implement actual location count current_locations = 1 - current_products = 0 return { "plan": subscription.plan, @@ -327,6 +330,32 @@ class SubscriptionLimitService: error=str(e)) return {"error": "Failed to get usage summary"} + async def _get_ingredient_count(self, tenant_id: str) -> int: + """Get ingredient count from inventory service using shared client""" + try: + from app.core.config import settings + from shared.clients.inventory_client import create_inventory_client + + # Use the shared inventory client with proper authentication + inventory_client = create_inventory_client(settings) + count = await inventory_client.count_ingredients(tenant_id) + + logger.info( + "Retrieved ingredient count via inventory client", + tenant_id=tenant_id, + count=count + ) + return count + + except Exception as e: + logger.error( + "Error getting ingredient count via inventory client", + tenant_id=tenant_id, + error=str(e) + ) + # Return 0 as fallback to avoid breaking subscription display + return 0 + # Legacy alias for backward compatibility SubscriptionService = SubscriptionLimitService \ No newline at end of file diff --git a/services/tenant/requirements.txt b/services/tenant/requirements.txt index e22e8a6f..0b2b46d8 100644 --- a/services/tenant/requirements.txt +++ b/services/tenant/requirements.txt @@ -12,4 +12,5 @@ prometheus-client==0.17.1 python-json-logger==2.0.4 pytz==2023.3 python-logstash==0.4.8 -structlog==23.2.0 \ No newline at end of file +structlog==23.2.0 +python-jose[cryptography]==3.3.0 \ No newline at end of file diff --git a/shared/clients/inventory_client.py b/shared/clients/inventory_client.py index fc52a958..cf7bcf2a 100644 --- a/shared/clients/inventory_client.py +++ b/shared/clients/inventory_client.py @@ -82,17 +82,36 @@ class InventoryServiceClient(BaseServiceClient): params = {} if is_active is not None: params["is_active"] = is_active - + ingredients = await self.get_paginated("ingredients", tenant_id=tenant_id, params=params) - - logger.info("Retrieved all ingredients from inventory service", + + logger.info("Retrieved all ingredients from inventory service", count=len(ingredients), tenant_id=tenant_id) return ingredients - + except Exception as e: - logger.error("Error fetching all ingredients", + logger.error("Error fetching all ingredients", error=str(e), tenant_id=tenant_id) return [] + + async def count_ingredients(self, tenant_id: str, is_active: Optional[bool] = True) -> int: + """Get count of ingredients for a tenant""" + try: + params = {} + if is_active is not None: + params["is_active"] = is_active + + result = await self.get("ingredients/count", tenant_id=tenant_id, params=params) + count = result.get("ingredient_count", 0) if isinstance(result, dict) else 0 + + logger.info("Retrieved ingredient count from inventory service", + count=count, tenant_id=tenant_id) + return count + + except Exception as e: + logger.error("Error fetching ingredient count", + error=str(e), tenant_id=tenant_id) + return 0 async def create_ingredient(self, ingredient_data: Dict[str, Any], tenant_id: str) -> Optional[Dict[str, Any]]: """Create a new ingredient"""