Add subcription level filtering

This commit is contained in:
Urtzi Alfaro
2025-09-21 13:27:50 +02:00
parent 29065f5337
commit e1b3184413
21 changed files with 1137 additions and 122 deletions

View File

@@ -9,6 +9,7 @@ import { AppRouter } from './router/AppRouter';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { SSEProvider } from './contexts/SSEContext'; import { SSEProvider } from './contexts/SSEContext';
import GlobalSubscriptionHandler from './components/auth/GlobalSubscriptionHandler';
import './i18n'; import './i18n';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -37,6 +38,7 @@ function App() {
<SSEProvider> <SSEProvider>
<Suspense fallback={<LoadingSpinner overlay />}> <Suspense fallback={<LoadingSpinner overlay />}>
<AppRouter /> <AppRouter />
<GlobalSubscriptionHandler />
<Toaster <Toaster
position="top-right" position="top-right"
toastOptions={{ toastOptions={{

View File

@@ -18,6 +18,27 @@ export interface ApiError {
details?: any; details?: any;
} }
export interface SubscriptionError {
error: string;
message: string;
code: string;
details: {
required_feature: string;
required_level: string;
current_plan: string;
upgrade_url: string;
};
}
// Subscription error event emitter
class SubscriptionErrorEmitter extends EventTarget {
emitSubscriptionError(error: SubscriptionError) {
this.dispatchEvent(new CustomEvent('subscriptionError', { detail: error }));
}
}
export const subscriptionErrorEmitter = new SubscriptionErrorEmitter();
class ApiClient { class ApiClient {
private client: AxiosInstance; private client: AxiosInstance;
private baseURL: string; private baseURL: string;
@@ -146,6 +167,13 @@ class ApiClient {
if (error.response) { if (error.response) {
// Server responded with error status // Server responded with error status
const { status, data } = error.response; const { status, data } = error.response;
// Check for subscription errors
if (status === 403 && (data as any)?.code === 'SUBSCRIPTION_UPGRADE_REQUIRED') {
const subscriptionError = data as SubscriptionError;
subscriptionErrorEmitter.emitSubscriptionError(subscriptionError);
}
return { return {
message: (data as any)?.detail || (data as any)?.message || `Request failed with status ${status}`, message: (data as any)?.detail || (data as any)?.message || `Request failed with status ${status}`,
status, status,

View File

@@ -4,9 +4,7 @@
import { apiClient } from '../client'; import { apiClient } from '../client';
import { import {
SubscriptionLimits, SubscriptionLimits,
FeatureCheckRequest,
FeatureCheckResponse, FeatureCheckResponse,
UsageCheckRequest,
UsageCheckResponse, UsageCheckResponse,
UsageSummary, UsageSummary,
AvailablePlans, AvailablePlans,
@@ -68,45 +66,35 @@ export class SubscriptionService {
} }
async getUsageSummary(tenantId: string): Promise<UsageSummary> { async getUsageSummary(tenantId: string): Promise<UsageSummary> {
try { return apiClient.get<UsageSummary>(`${this.baseUrl}/${tenantId}/usage`);
return await apiClient.get<UsageSummary>(`${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();
}
} }
async getAvailablePlans(): Promise<AvailablePlans> { async getAvailablePlans(): Promise<AvailablePlans> {
try { return apiClient.get<AvailablePlans>('/subscriptions/plans');
return await apiClient.get<AvailablePlans>(`${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();
}
} }
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> { async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
try { return apiClient.get<PlanUpgradeValidation>(`${this.baseUrl}/${tenantId}/validate-upgrade/${planKey}`);
return await apiClient.post<PlanUpgradeValidation>(`${this.baseUrl}/${tenantId}/validate-upgrade`, {
plan: planKey
});
} catch (error) {
console.warn('Using mock validation - backend endpoint not implemented yet');
return { can_upgrade: true };
}
} }
async upgradePlan(tenantId: string, planKey: string): Promise<PlanUpgradeResult> { async upgradePlan(tenantId: string, planKey: string): Promise<PlanUpgradeResult> {
try { return apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/${tenantId}/upgrade?new_plan=${planKey}`, {});
return await apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/${tenantId}/upgrade`, { }
plan: planKey
}); async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
} catch (error) { return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-location`);
console.warn('Using mock upgrade - backend endpoint not implemented yet'); }
return { success: true, message: 'Plan actualizado correctamente (modo demo)' };
} 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 { formatPrice(amount: number): string {
@@ -126,69 +114,6 @@ export class SubscriptionService {
}; };
return planInfo[planKey as keyof typeof planInfo] || { name: 'Desconocido', color: 'gray' }; 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(); export const subscriptionService = new SubscriptionService();

View File

@@ -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<SubscriptionError | null>(null);
const [showErrorDialog, setShowErrorDialog] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const handleSubscriptionError = (event: CustomEvent<SubscriptionError>) => {
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 (
<SubscriptionErrorHandler
error={subscriptionError}
isOpen={showErrorDialog}
onClose={handleClose}
onUpgrade={handleUpgrade}
/>
);
};
export default GlobalSubscriptionHandler;
export { GlobalSubscriptionHandler };

View File

@@ -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<SubscriptionErrorHandlerProps> = ({
error,
isOpen,
onClose,
onUpgrade
}) => {
const getFeatureDisplayName = (feature: string) => {
const featureNames: Record<string, string> = {
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<string, string> = {
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 (
<Modal isOpen={isOpen} onClose={onClose} size="md">
<div className="p-6">
<div className="text-center mb-6">
<div className={`w-16 h-16 ${getPlanColor(requiredPlan)} rounded-full flex items-center justify-center mx-auto mb-4`}>
<Crown className="w-8 h-8 text-white" />
</div>
<h2 className="text-xl font-bold text-[var(--text-primary)] mb-2">
Actualización de Plan Requerida
</h2>
<p className="text-[var(--text-secondary)]">
Esta funcionalidad requiere un plan {requiredPlan} o superior
</p>
</div>
<Card className="bg-[var(--bg-secondary)] border-0 p-4 mb-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Funcionalidad:</span>
<span className="font-medium text-[var(--text-primary)]">{featureName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Nivel requerido:</span>
<span className="font-medium text-[var(--text-primary)]">{levelName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Plan actual:</span>
<span className="font-medium text-[var(--text-primary)] capitalize">{error.details.current_plan}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Plan requerido:</span>
<span className="font-medium text-[var(--color-primary)]">{requiredPlan}</span>
</div>
</div>
</Card>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-amber-800 font-medium mb-1">
Acceso Restringido
</p>
<p className="text-xs text-amber-700">
{error.message}
</p>
</div>
</div>
</div>
<div className="space-y-3">
<Button
onClick={onUpgrade}
className="w-full bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
>
Ver Planes y Actualizar
<ArrowRight className="w-4 h-4" />
</Button>
<Button
onClick={onClose}
variant="outline"
className="w-full text-[var(--text-secondary)] py-2 px-4 rounded-lg border border-[var(--border-color)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Entendido
</Button>
</div>
<div className="mt-6 text-center text-xs text-[var(--text-secondary)] flex items-center justify-center gap-1">
<Lock className="w-3 h-3" />
Funcionalidad protegida por suscripción
</div>
</div>
</Modal>
);
};
export default SubscriptionErrorHandler;

View File

@@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated } from '../../../stores'; import { useAuthUser, useIsAuthenticated } from '../../../stores';
import { useCurrentTenantAccess } from '../../../stores/tenant.store'; import { useCurrentTenantAccess } from '../../../stores/tenant.store';
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config'; import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
import { Button } from '../../ui'; import { Button } from '../../ui';
import { Badge } from '../../ui'; import { Badge } from '../../ui';
import { Tooltip } from '../../ui'; import { Tooltip } from '../../ui';
@@ -136,11 +137,13 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const [hoveredItem, setHoveredItem] = useState<string | null>(null); const [hoveredItem, setHoveredItem] = useState<string | null>(null);
const sidebarRef = React.useRef<HTMLDivElement>(null); const sidebarRef = React.useRef<HTMLDivElement>(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 navigationItems = useMemo(() => {
const navigationRoutes = getNavigationRoutes(); const convertRoutesToItems = (routes: typeof subscriptionFilteredRoutes): NavigationItem[] => {
const convertRoutesToItems = (routes: typeof navigationRoutes): NavigationItem[] => {
return routes.map(route => ({ return routes.map(route => ({
id: route.path, id: route.path,
label: route.title, label: route.title,
@@ -152,8 +155,8 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
})); }));
}; };
return customItems || convertRoutesToItems(navigationRoutes); return customItems || convertRoutesToItems(subscriptionFilteredRoutes);
}, [customItems]); }, [customItems, subscriptionFilteredRoutes]);
// Filter items based on user permissions - memoized to prevent infinite re-renders // Filter items based on user permissions - memoized to prevent infinite re-renders
const visibleItems = useMemo(() => { const visibleItems = useMemo(() => {

View File

@@ -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<string, any>;
loading: boolean;
error?: string;
}
export const useSubscription = () => {
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo>({
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<SubscriptionFeature> => {
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<SubscriptionLimits> => {
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;

View File

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

View File

@@ -1209,6 +1209,67 @@ const ProfilePage: React.FC = () => {
</div> </div>
</div> </div>
{/* Features Section */}
<div className="border-t border-[var(--border-color)] pt-4 mb-6">
<h5 className="text-sm font-semibold text-[var(--text-primary)] mb-3 flex items-center">
<TrendingUp className="w-4 h-4 mr-2 text-[var(--color-primary)]" />
Funcionalidades Incluidas
</h5>
<div className="space-y-2">
{(() => {
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) => (
<div key={index} className={`text-xs flex items-center gap-2 ${
feature.startsWith('✓')
? 'text-green-600'
: 'text-[var(--text-secondary)] opacity-60'
}`}>
<span>{feature}</span>
</div>
));
})()}
</div>
</div>
{isCurrentPlan ? ( {isCurrentPlan ? (
<Badge variant="success" className="w-full justify-center py-2"> <Badge variant="success" className="w-full justify-center py-2">
<CheckCircle className="w-4 h-4 mr-2" /> <CheckCircle className="w-4 h-4 mr-2" />

View File

@@ -14,6 +14,8 @@ export interface RouteConfig {
requiresAuth: boolean; requiresAuth: boolean;
requiredRoles?: string[]; requiredRoles?: string[];
requiredPermissions?: string[]; requiredPermissions?: string[];
requiredSubscriptionFeature?: string;
requiredAnalyticsLevel?: 'basic' | 'advanced' | 'predictive';
showInNavigation?: boolean; showInNavigation?: boolean;
showInBreadcrumbs?: boolean; showInBreadcrumbs?: boolean;
children?: RouteConfig[]; children?: RouteConfig[];
@@ -343,7 +345,7 @@ export const routesConfig: RouteConfig[] = [
], ],
}, },
// Analytics Section // Analytics Section - Subscription protected
{ {
path: '/app/analytics', path: '/app/analytics',
name: 'Analytics', name: 'Analytics',
@@ -352,6 +354,7 @@ export const routesConfig: RouteConfig[] = [
icon: 'sales', icon: 'sales',
requiresAuth: true, requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS, requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
requiredAnalyticsLevel: 'basic',
showInNavigation: true, showInNavigation: true,
children: [ children: [
{ {
@@ -362,6 +365,7 @@ export const routesConfig: RouteConfig[] = [
icon: 'forecasting', icon: 'forecasting',
requiresAuth: true, requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS, requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
requiredAnalyticsLevel: 'advanced',
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },
@@ -373,6 +377,7 @@ export const routesConfig: RouteConfig[] = [
icon: 'sales', icon: 'sales',
requiresAuth: true, requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS, requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
requiredAnalyticsLevel: 'advanced',
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },
@@ -384,6 +389,7 @@ export const routesConfig: RouteConfig[] = [
icon: 'sales', icon: 'sales',
requiresAuth: true, requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS, requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
requiredAnalyticsLevel: 'advanced',
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },
@@ -395,6 +401,7 @@ export const routesConfig: RouteConfig[] = [
icon: 'forecasting', icon: 'forecasting',
requiresAuth: true, requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS, requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
requiredAnalyticsLevel: 'predictive',
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },

View File

@@ -18,7 +18,8 @@ from app.core.service_discovery import ServiceDiscovery
from app.middleware.auth import AuthMiddleware from app.middleware.auth import AuthMiddleware
from app.middleware.logging import LoggingMiddleware from app.middleware.logging import LoggingMiddleware
from app.middleware.rate_limit import RateLimitMiddleware 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.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector from shared.monitoring.metrics import MetricsCollector
@@ -56,12 +57,14 @@ app.add_middleware(
# Custom middleware - Add in correct order (outer to inner) # Custom middleware - Add in correct order (outer to inner)
app.add_middleware(LoggingMiddleware) app.add_middleware(LoggingMiddleware)
app.add_middleware(RateLimitMiddleware, calls_per_minute=300) app.add_middleware(RateLimitMiddleware, calls_per_minute=300)
app.add_middleware(SubscriptionMiddleware, tenant_service_url=settings.TENANT_SERVICE_URL)
app.add_middleware(AuthMiddleware) app.add_middleware(AuthMiddleware)
# Include routers # Include routers
app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"]) 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(user.router, prefix="/api/v1/users", tags=["users"])
app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"]) 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(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"]) app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"])

View File

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

View File

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

View File

@@ -53,6 +53,22 @@ async def delete_user_tenants(request: Request, user_id: str = Path(...)):
"""Get all tenant memberships for a user (admin only)""" """Get all tenant memberships for a user (admin only)"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/user/{user_id}/memberships") 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 # TENANT-SCOPED DATA SERVICE ENDPOINTS
# ================================================================ # ================================================================

View File

@@ -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) @router.get("/tenants/{tenant_id}/ingredients/{ingredient_id}", response_model=IngredientResponse)
async def get_ingredient( async def get_ingredient(
ingredient_id: UUID, ingredient_id: UUID,

View File

@@ -219,6 +219,30 @@ class InventoryService:
logger.error("Failed to get ingredients", error=str(e), tenant_id=tenant_id) logger.error("Failed to get ingredients", error=str(e), tenant_id=tenant_id)
raise 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 ===== # ===== STOCK MANAGEMENT =====
async def add_stock( async def add_stock(

View File

@@ -39,7 +39,6 @@ def get_subscription_repository():
raise HTTPException(status_code=500, detail="Repository initialization failed") raise HTTPException(status_code=500, detail="Repository initialization failed")
@router.get("/subscriptions/{tenant_id}/limits") @router.get("/subscriptions/{tenant_id}/limits")
@track_endpoint_metrics("subscription_get_limits")
async def get_subscription_limits( async def get_subscription_limits(
tenant_id: UUID = Path(..., description="Tenant ID"), tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), 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") @router.get("/subscriptions/{tenant_id}/usage")
@track_endpoint_metrics("subscription_get_usage")
async def get_usage_summary( async def get_usage_summary(
tenant_id: UUID = Path(..., description="Tenant ID"), tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), 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") @router.get("/subscriptions/{tenant_id}/can-add-location")
@track_endpoint_metrics("subscription_check_location_limit")
async def can_add_location( async def can_add_location(
tenant_id: UUID = Path(..., description="Tenant ID"), tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), 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") @router.get("/subscriptions/{tenant_id}/can-add-product")
@track_endpoint_metrics("subscription_check_product_limit")
async def can_add_product( async def can_add_product(
tenant_id: UUID = Path(..., description="Tenant ID"), tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), 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") @router.get("/subscriptions/{tenant_id}/can-add-user")
@track_endpoint_metrics("subscription_check_user_limit")
async def can_add_user( async def can_add_user(
tenant_id: UUID = Path(..., description="Tenant ID"), tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), 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}") @router.get("/subscriptions/{tenant_id}/features/{feature}")
@track_endpoint_metrics("subscription_check_feature")
async def has_feature( async def has_feature(
tenant_id: UUID = Path(..., description="Tenant ID"), tenant_id: UUID = Path(..., description="Tenant ID"),
feature: str = Path(..., description="Feature name"), feature: str = Path(..., description="Feature name"),
@@ -179,7 +173,6 @@ async def has_feature(
) )
@router.get("/subscriptions/{tenant_id}/validate-upgrade/{new_plan}") @router.get("/subscriptions/{tenant_id}/validate-upgrade/{new_plan}")
@track_endpoint_metrics("subscription_validate_upgrade")
async def validate_plan_upgrade( async def validate_plan_upgrade(
tenant_id: UUID = Path(..., description="Tenant ID"), tenant_id: UUID = Path(..., description="Tenant ID"),
new_plan: str = Path(..., description="New plan name"), new_plan: str = Path(..., description="New plan name"),
@@ -204,7 +197,6 @@ async def validate_plan_upgrade(
) )
@router.post("/subscriptions/{tenant_id}/upgrade") @router.post("/subscriptions/{tenant_id}/upgrade")
@track_endpoint_metrics("subscription_upgrade_plan")
async def upgrade_subscription_plan( async def upgrade_subscription_plan(
tenant_id: UUID = Path(..., description="Tenant ID"), tenant_id: UUID = Path(..., description="Tenant ID"),
new_plan: str = Query(..., description="New plan name"), new_plan: str = Query(..., description="New plan name"),
@@ -250,7 +242,6 @@ async def upgrade_subscription_plan(
) )
@router.get("/plans/available") @router.get("/plans/available")
@track_endpoint_metrics("subscription_get_available_plans")
async def get_available_plans(): async def get_available_plans():
"""Get all available subscription plans with features and pricing""" """Get all available subscription plans with features and pricing"""

View File

@@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings from app.core.config import settings
from app.core.database import database_manager 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.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector from shared.monitoring.metrics import MetricsCollector
@@ -41,6 +41,7 @@ app.add_middleware(
# Include routers # Include routers
app.include_router(tenants.router, prefix="/api/v1", tags=["tenants"]) app.include_router(tenants.router, prefix="/api/v1", tags=["tenants"])
app.include_router(subscriptions.router, prefix="/api/v1", tags=["subscriptions"])
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():

View File

@@ -7,6 +7,7 @@ import structlog
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import HTTPException, status from fastapi import HTTPException, status
import httpx
from app.repositories import SubscriptionRepository, TenantRepository, TenantMemberRepository from app.repositories import SubscriptionRepository, TenantRepository, TenantMemberRepository
from app.models.tenants import Subscription, Tenant, TenantMember from app.models.tenants import Subscription, Tenant, TenantMember
@@ -287,10 +288,12 @@ class SubscriptionLimitService:
# Get current usage # Get current usage
members = await self.member_repo.get_tenant_members(tenant_id, active_only=True) members = await self.member_repo.get_tenant_members(tenant_id, active_only=True)
current_users = len(members) 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_locations = 1
current_products = 0
return { return {
"plan": subscription.plan, "plan": subscription.plan,
@@ -327,6 +330,32 @@ class SubscriptionLimitService:
error=str(e)) error=str(e))
return {"error": "Failed to get usage summary"} 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 # Legacy alias for backward compatibility
SubscriptionService = SubscriptionLimitService SubscriptionService = SubscriptionLimitService

View File

@@ -12,4 +12,5 @@ prometheus-client==0.17.1
python-json-logger==2.0.4 python-json-logger==2.0.4
pytz==2023.3 pytz==2023.3
python-logstash==0.4.8 python-logstash==0.4.8
structlog==23.2.0 structlog==23.2.0
python-jose[cryptography]==3.3.0

View File

@@ -82,17 +82,36 @@ class InventoryServiceClient(BaseServiceClient):
params = {} params = {}
if is_active is not None: if is_active is not None:
params["is_active"] = is_active params["is_active"] = is_active
ingredients = await self.get_paginated("ingredients", tenant_id=tenant_id, params=params) 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) count=len(ingredients), tenant_id=tenant_id)
return ingredients return ingredients
except Exception as e: except Exception as e:
logger.error("Error fetching all ingredients", logger.error("Error fetching all ingredients",
error=str(e), tenant_id=tenant_id) error=str(e), tenant_id=tenant_id)
return [] 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]]: async def create_ingredient(self, ingredient_data: Dict[str, Any], tenant_id: str) -> Optional[Dict[str, Any]]:
"""Create a new ingredient""" """Create a new ingredient"""