Add subcription level filtering
This commit is contained in:
@@ -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={{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
60
frontend/src/components/auth/GlobalSubscriptionHandler.tsx
Normal file
60
frontend/src/components/auth/GlobalSubscriptionHandler.tsx
Normal 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 };
|
||||||
157
frontend/src/components/auth/SubscriptionErrorHandler.tsx
Normal file
157
frontend/src/components/auth/SubscriptionErrorHandler.tsx
Normal 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;
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
179
frontend/src/hooks/useSubscription.ts
Normal file
179
frontend/src/hooks/useSubscription.ts
Normal 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;
|
||||||
69
frontend/src/hooks/useSubscriptionAwareRoutes.ts
Normal file
69
frontend/src/hooks/useSubscriptionAwareRoutes.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
298
gateway/app/middleware/subscription.py
Normal file
298
gateway/app/middleware/subscription.py
Normal 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
|
||||||
118
gateway/app/routes/subscription.py
Normal file
118
gateway/app/routes/subscription.py
Normal 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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"""
|
||||||
|
|||||||
Reference in New Issue
Block a user