diff --git a/frontend/src/components/layout/Sidebar/Sidebar.tsx b/frontend/src/components/layout/Sidebar/Sidebar.tsx index 40935322..6921180e 100644 --- a/frontend/src/components/layout/Sidebar/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar/Sidebar.tsx @@ -20,6 +20,7 @@ import { Bell, Settings, User, + CreditCard, ChevronLeft, ChevronRight, ChevronDown, @@ -95,6 +96,7 @@ const iconMap: Record> = { notifications: Bell, settings: Settings, user: User, + 'credit-card': CreditCard, }; /** diff --git a/frontend/src/config/mock.config.ts b/frontend/src/config/mock.config.ts index 95810efb..d830eef0 100644 --- a/frontend/src/config/mock.config.ts +++ b/frontend/src/config/mock.config.ts @@ -20,6 +20,14 @@ export const MOCK_CONFIG = { tenant_name: 'Panadería Artesanal El Buen Pan' }, + // Mock admin user data for testing + MOCK_ADMIN_USER: { + full_name: 'Admin User', + email: 'admin@bakery.com', + role: 'admin', + tenant_name: 'Bakery Admin Demo' + }, + // Mock bakery data MOCK_BAKERY: { name: 'Panadería Artesanal El Buen Pan', @@ -27,6 +35,69 @@ export const MOCK_CONFIG = { location: 'Av. Principal 123, Centro Histórico', phone: '+1 234 567 8900', email: 'info@elbuenpan.com' + }, + + // Mock subscription data for admin@bakery.com + MOCK_SUBSCRIPTION: { + id: 'sub_admin_demo_001', + tenant_id: 'tenant_admin_demo', + plan: 'professional', + status: 'active', + monthly_price: 129.0, + currency: 'EUR', + billing_cycle: 'monthly', + current_period_start: '2024-08-01T00:00:00Z', + current_period_end: '2024-09-01T00:00:00Z', + next_billing_date: '2024-09-01T00:00:00Z', + trial_ends_at: null, + canceled_at: null, + created_at: '2024-01-15T10:30:00Z', + updated_at: '2024-08-01T00:00:00Z', + max_users: 15, + max_locations: 2, + max_products: -1, + features: { + inventory_management: 'advanced', + demand_prediction: 'ai_92_percent', + production_management: 'complete', + pos_integrated: true, + logistics: 'basic', + analytics: 'advanced', + support: 'priority_24_7', + trial_days: 14, + locations: '1_2_locations' + }, + usage: { + users: 8, + locations: 1, + products: 145, + storage_gb: 2.4, + api_calls_month: 1250, + reports_generated: 23 + }, + billing_history: [ + { + id: 'inv_001', + date: '2024-08-01T00:00:00Z', + amount: 129.0, + status: 'paid' as 'paid', + description: 'Plan Professional - Agosto 2024' + }, + { + id: 'inv_002', + date: '2024-07-01T00:00:00Z', + amount: 129.0, + status: 'paid' as 'paid', + description: 'Plan Professional - Julio 2024' + }, + { + id: 'inv_003', + date: '2024-06-01T00:00:00Z', + amount: 129.0, + status: 'paid' as 'paid', + description: 'Plan Professional - Junio 2024' + } + ] } }; @@ -34,4 +105,8 @@ export const MOCK_CONFIG = { export const isMockMode = () => MOCK_CONFIG.MOCK_MODE; export const isMockRegistration = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_REGISTRATION; export const isMockAuthentication = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_AUTHENTICATION; -export const isMockOnboardingFlow = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_ONBOARDING_FLOW; \ No newline at end of file +export const isMockOnboardingFlow = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_ONBOARDING_FLOW; + +// Helper functions to get mock data +export const getMockUser = (isAdmin = false) => isAdmin ? MOCK_CONFIG.MOCK_ADMIN_USER : MOCK_CONFIG.MOCK_USER; +export const getMockSubscription = () => MOCK_CONFIG.MOCK_SUBSCRIPTION; \ No newline at end of file diff --git a/frontend/src/pages/app/settings/index.ts b/frontend/src/pages/app/settings/index.ts new file mode 100644 index 00000000..5df3b58f --- /dev/null +++ b/frontend/src/pages/app/settings/index.ts @@ -0,0 +1,7 @@ +// Settings pages +export { default as ProfilePage } from './profile'; +export { default as BakeryConfigPage } from './bakery-config'; +export { default as TeamPage } from './team'; +export { default as SystemSettingsPage } from './system'; +export { default as TrainingPage } from './training'; +export { default as SubscriptionPage } from './subscription'; \ No newline at end of file diff --git a/frontend/src/pages/app/settings/subscription/SubscriptionPage.tsx b/frontend/src/pages/app/settings/subscription/SubscriptionPage.tsx new file mode 100644 index 00000000..826af1fd --- /dev/null +++ b/frontend/src/pages/app/settings/subscription/SubscriptionPage.tsx @@ -0,0 +1,855 @@ +/** + * Subscription Management Page + * Allows users to view current subscription, billing details, and upgrade plans + */ + +import React, { useState, useEffect } from 'react'; +import { + Card, + Button, + Badge, + Modal +} from '../../../../components/ui'; +import { PageHeader } from '../../../../components/layout'; +import { + CreditCard, + Users, + MapPin, + Package, + TrendingUp, + Calendar, + CheckCircle, + AlertCircle, + ArrowRight, + Crown, + Star, + Zap, + X, + RefreshCw, + Settings, + Download, + ExternalLink +} from 'lucide-react'; +import { useAuth } from '../../../../hooks/api/useAuth'; +import { useBakeryStore } from '../../../../stores/bakery.store'; +import { useToast } from '../../../../hooks/ui/useToast'; +import { + subscriptionService, + type UsageSummary, + type AvailablePlans +} from '../../../../services/api'; +import { isMockMode, getMockSubscription } from '../../../../config/mock.config'; + +interface PlanComparisonProps { + plans: AvailablePlans['plans']; + currentPlan: string; + onUpgrade: (planKey: string) => void; +} + +const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => { + const getProgressColor = () => { + if (value >= 90) return 'bg-red-500'; + if (value >= 80) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + + return ( +
+
+
+
+
+ ); +}; + +// Tabs implementation +interface TabsProps { + defaultValue: string; + className?: string; + children: React.ReactNode; +} + +interface TabsListProps { + className?: string; + children: React.ReactNode; +} + +interface TabsTriggerProps { + value: string; + children: React.ReactNode; + className?: string; +} + +interface TabsContentProps { + value: string; + children: React.ReactNode; + className?: string; +} + +const TabsContext = React.createContext<{ activeTab: string; setActiveTab: (value: string) => void } | null>(null); + +const Tabs: React.FC & { + List: React.FC; + Trigger: React.FC; + Content: React.FC; +} = ({ + defaultValue, + className = '', + children +}) => { + const [activeTab, setActiveTab] = useState(defaultValue); + + return ( +
+ + {children} + +
+ ); +}; + +const TabsList: React.FC = ({ className = '', children }) => { + return ( +
+ {children} +
+ ); +}; + +const TabsTrigger: React.FC = ({ value, children, className = '' }) => { + const context = React.useContext(TabsContext); + if (!context) throw new Error('TabsTrigger must be used within Tabs'); + + const { activeTab, setActiveTab } = context; + const isActive = activeTab === value; + + return ( + + ); +}; + +const TabsContent: React.FC = ({ value, children, className = '' }) => { + const context = React.useContext(TabsContext); + if (!context) throw new Error('TabsContent must be used within Tabs'); + + const { activeTab } = context; + + if (activeTab !== value) return null; + + return ( +
+ {children} +
+ ); +}; + +Tabs.List = TabsList; +Tabs.Trigger = TabsTrigger; +Tabs.Content = TabsContent; + +const PlanComparison: React.FC = ({ plans, currentPlan, onUpgrade }) => { + const planOrder = ['starter', 'professional', 'enterprise']; + const sortedPlans = Object.entries(plans).sort(([a], [b]) => + planOrder.indexOf(a) - planOrder.indexOf(b) + ); + + const getPlanColor = (planKey: string) => { + switch (planKey) { + case 'starter': return 'border-blue-500/30 bg-blue-500/5'; + case 'professional': return 'border-purple-500/30 bg-purple-500/5'; + case 'enterprise': return 'border-amber-500/30 bg-amber-500/5'; + default: return 'border-[var(--border-primary)] bg-[var(--bg-secondary)]'; + } + }; + + return ( +
+ {sortedPlans.map(([planKey, plan]) => ( + + {plan.popular && ( +
+ + + Más Popular + +
+ )} + +
+

{plan.name}

+
+ {subscriptionService.formatPrice(plan.monthly_price)} + /mes +
+

{plan.description}

+
+ +
+
+ + {plan.max_users === -1 ? 'Usuarios ilimitados' : `${plan.max_users} usuarios`} +
+
+ + {plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`} +
+
+ + {plan.max_products === -1 ? 'Productos ilimitados' : `${plan.max_products} productos`} +
+
+ + {currentPlan === planKey ? ( + + + Plan Actual + + ) : ( + + )} +
+ ))} +
+ ); +}; + +const SubscriptionPage: React.FC = () => { + const { user, tenant_id } = useAuth(); + const { currentTenant } = useBakeryStore(); + const toast = useToast(); + const [usageSummary, setUsageSummary] = useState(null); + const [availablePlans, setAvailablePlans] = useState(null); + const [loading, setLoading] = useState(true); + const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); + const [selectedPlan, setSelectedPlan] = useState(''); + const [upgrading, setUpgrading] = useState(false); + + useEffect(() => { + if (currentTenant?.id || tenant_id || isMockMode()) { + loadSubscriptionData(); + } + }, [currentTenant, tenant_id]); + + const loadSubscriptionData = async () => { + let tenantId = currentTenant?.id || tenant_id; + + // In mock mode, use the mock tenant ID if no real tenant is available + if (isMockMode() && !tenantId) { + tenantId = getMockSubscription().tenant_id; + console.log('🧪 Mock mode: Using mock tenant ID:', tenantId); + } + + console.log('📊 Loading subscription data for tenant:', tenantId, '| Mock mode:', isMockMode()); + + if (!tenantId) return; + + try { + setLoading(true); + const [usage, plans] = await Promise.all([ + subscriptionService.getUsageSummary(tenantId), + subscriptionService.getAvailablePlans() + ]); + + setUsageSummary(usage); + setAvailablePlans(plans); + } catch (error) { + console.error('Error loading subscription data:', error); + toast.error("No se pudo cargar la información de suscripción"); + } finally { + setLoading(false); + } + }; + + const handleUpgradeClick = (planKey: string) => { + setSelectedPlan(planKey); + setUpgradeDialogOpen(true); + }; + + const handleUpgradeConfirm = async () => { + let tenantId = currentTenant?.id || tenant_id; + + // In mock mode, use the mock tenant ID if no real tenant is available + if (isMockMode() && !tenantId) { + tenantId = getMockSubscription().tenant_id; + } + + if (!tenantId || !selectedPlan) return; + + try { + setUpgrading(true); + + const validation = await subscriptionService.validatePlanUpgrade( + tenantId, + selectedPlan + ); + + if (!validation.can_upgrade) { + toast.error(validation.reason); + return; + } + + const result = await subscriptionService.upgradePlan(tenantId, selectedPlan); + + if (result.success) { + toast.success(result.message); + + await loadSubscriptionData(); + setUpgradeDialogOpen(false); + setSelectedPlan(''); + } else { + toast.error('Error al cambiar el plan'); + } + } catch (error) { + console.error('Error upgrading plan:', error); + toast.error('Error al procesar el cambio de plan'); + } finally { + setUpgrading(false); + } + }; + + if (loading) { + return ( +
+ +
+
+ +

Cargando información de suscripción...

+
+
+
+ ); + } + + if (!usageSummary || !availablePlans) { + return ( +
+ +
+
+ +
+

No se pudo cargar la información

+

Hubo un problema al cargar los datos de suscripción

+ +
+
+
+
+ ); + } + + const nextBillingDate = usageSummary.next_billing_date + ? new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + : 'No disponible'; + + const planInfo = subscriptionService.getPlanDisplayInfo(usageSummary.plan); + + return ( +
+ window.open('https://billing.bakery.com', '_blank'), + variant: 'outline' + }, + { + id: 'download-invoice', + label: 'Descargar Factura', + icon: Download, + onClick: () => console.log('Download latest invoice'), + variant: 'outline' + } + ]} + metadata={[ + { + id: 'next-billing', + label: 'Próxima facturación', + value: nextBillingDate, + icon: Calendar + }, + { + id: 'monthly-cost', + label: 'Coste mensual', + value: subscriptionService.formatPrice(usageSummary.monthly_price), + icon: CreditCard + } + ]} + onRefresh={loadSubscriptionData} + showRefreshButton + /> + + {/* Quick Stats Overview */} +
+ +
+
+ +
+
+

Usuarios

+

+ {usageSummary.usage.users.current}/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit} +

+
+
+
+ + +
+
+ +
+
+

Ubicaciones

+

+ {usageSummary.usage.locations.current}/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit} +

+
+
+
+ + +
+
+ +
+
+

Productos

+

+ {usageSummary.usage.products.current}/{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit} +

+
+
+
+ + +
+
+ +
+
+

Estado

+ + {usageSummary.status === 'active' ? 'Activo' : usageSummary.status} + +
+
+
+
+ + + + + + + Resumen + + + + Uso + + + + Planes + + + + Facturación + + + + +
+ {/* Current Plan Summary */} + +

+ + Tu Plan Actual +

+
+
+
+
+ Plan +
+ {planInfo.name} + {usageSummary.plan === 'professional' && ( + + + Popular + + )} +
+
+
+
+
+ Precio + {subscriptionService.formatPrice(usageSummary.monthly_price)}/mes +
+
+
+
+
+
+ Estado + + {usageSummary.status === 'active' ? 'Activo' : 'Inactivo'} + +
+
+
+
+ Próxima facturación + {nextBillingDate} +
+
+
+
+
+ + {/* Quick Actions */} + +

+ Acciones Rápidas +

+
+ + + +
+
+
+ + {/* Usage at a Glance */} + +

+ + Uso de Recursos +

+
+ {/* Users */} +
+
+
+
+ +
+ Usuarios +
+ + {usageSummary.usage.users.current}/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit} + +
+ +

+ {usageSummary.usage.users.usage_percentage}% utilizado + {usageSummary.usage.users.unlimited ? 'Ilimitado' : `${usageSummary.usage.users.limit - usageSummary.usage.users.current} restantes`} +

+
+ + {/* Locations */} +
+
+
+
+ +
+ Ubicaciones +
+ + {usageSummary.usage.locations.current}/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit} + +
+ +

+ {usageSummary.usage.locations.usage_percentage}% utilizado + {usageSummary.usage.locations.unlimited ? 'Ilimitado' : `${usageSummary.usage.locations.limit - usageSummary.usage.locations.current} restantes`} +

+
+ + {/* Products */} +
+
+
+
+ +
+ Productos +
+ + {usageSummary.usage.products.current}/{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit} + +
+ +

+ {usageSummary.usage.products.usage_percentage}% utilizado + {usageSummary.usage.products.unlimited ? 'Ilimitado' : 'Ilimitado'} +

+
+
+
+
+ + +
+ +

Detalles de Uso

+
+ {/* Users Usage */} +
+
+ +

Gestión de Usuarios

+
+
+
+ Usuarios activos + {usageSummary.usage.users.current} +
+
+ Límite del plan + {usageSummary.usage.users.unlimited ? 'Ilimitado' : usageSummary.usage.users.limit} +
+ +

+ {usageSummary.usage.users.usage_percentage}% de capacidad utilizada +

+
+
+ + {/* Locations Usage */} +
+
+ +

Ubicaciones

+
+
+
+ Ubicaciones activas + {usageSummary.usage.locations.current} +
+
+ Límite del plan + {usageSummary.usage.locations.unlimited ? 'Ilimitado' : usageSummary.usage.locations.limit} +
+ +

+ {usageSummary.usage.locations.usage_percentage}% de capacidad utilizada +

+
+
+ + {/* Products Usage */} +
+
+ +

Productos

+
+
+
+ Productos registrados + {usageSummary.usage.products.current} +
+
+ Límite del plan + {usageSummary.usage.products.unlimited ? 'Ilimitado' : usageSummary.usage.products.limit} +
+ +

+ {usageSummary.usage.products.usage_percentage}% de capacidad utilizada +

+
+
+
+
+
+
+ + +
+
+

+ Planes de Suscripción +

+

+ Elige el plan que mejor se adapte a las necesidades de tu panadería +

+
+ +
+
+ + +
+ +

+ Información de Facturación +

+
+
+ Plan actual: + {planInfo.name} +
+
+ Precio mensual: + {subscriptionService.formatPrice(usageSummary.monthly_price)} +
+
+ Próxima facturación: + + + {nextBillingDate} + +
+
+
+ + +

+ Métodos de Pago +

+
+
+ +
+
•••• •••• •••• 4242
+
Visa terminada en 4242
+
+ Principal +
+ +
+
+
+
+
+
+ + {/* Upgrade Modal */} + {upgradeDialogOpen && selectedPlan && availablePlans && ( + setUpgradeDialogOpen(false)} + title="Confirmar Cambio de Plan" + > +
+

+ ¿Estás seguro de que quieres cambiar tu plan de suscripción? +

+ {availablePlans.plans[selectedPlan] && ( +
+
+ Plan actual: + {planInfo.name} +
+
+ Nuevo plan: + {availablePlans.plans[selectedPlan].name} +
+
+ Nuevo precio: + {subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes +
+
+ )} + +
+ + +
+
+
+ )} +
+ ); +}; + +export default SubscriptionPage; \ No newline at end of file diff --git a/frontend/src/pages/app/settings/subscription/SubscriptionPageOld.tsx b/frontend/src/pages/app/settings/subscription/SubscriptionPageOld.tsx new file mode 100644 index 00000000..8ea0cc22 --- /dev/null +++ b/frontend/src/pages/app/settings/subscription/SubscriptionPageOld.tsx @@ -0,0 +1,718 @@ +/** + * Subscription Management Page + * Allows users to view current subscription, billing details, and upgrade plans + */ + +import React, { useState, useEffect } from 'react'; +import { + Card, + Button, + Badge, + Modal +} from '../../../../components/ui'; +import { PageHeader } from '../../../../components/layout'; +import { + CreditCard, + Users, + MapPin, + Package, + TrendingUp, + Calendar, + CheckCircle, + AlertCircle, + ArrowRight, + Crown, + Star, + Zap, + X, + RefreshCw, + Settings, + Download, + ExternalLink +} from 'lucide-react'; +import { useAuth } from '../../../../hooks/api/useAuth'; +import { useBakeryStore } from '../../../../stores/bakery.store'; +import { useToast } from '../../../../hooks/ui/useToast'; +import { + subscriptionService, + type UsageSummary, + type AvailablePlans +} from '../../../../services/api'; +import { isMockMode, getMockSubscription } from '../../../../config/mock.config'; + +interface PlanComparisonProps { + plans: AvailablePlans['plans']; + currentPlan: string; + onUpgrade: (planKey: string) => void; +} + +const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => { + const getProgressColor = () => { + if (value >= 90) return 'bg-red-500'; + if (value >= 80) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + + return ( +
+
+
+ ); +}; + +const Alert: React.FC<{ children: React.ReactNode; className?: string }> = ({ + children, + className = '' +}) => { + return ( +
+ {children} +
+ ); +}; + +const Tabs: React.FC<{ defaultValue: string; className?: string; children: React.ReactNode }> = ({ + defaultValue, + className = '', + children +}) => { + const [activeTab, setActiveTab] = useState(defaultValue); + + return ( +
+ {React.Children.map(children, child => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { activeTab, setActiveTab } as any); + } + return child; + })} +
+ ); +}; + +const TabsList: React.FC<{ children: React.ReactNode; activeTab?: string; setActiveTab?: (tab: string) => void }> = ({ + children, + activeTab, + setActiveTab +}) => { + return ( +
+ {React.Children.map(children, child => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { activeTab, setActiveTab } as any); + } + return child; + })} +
+ ); +}; + +const TabsTrigger: React.FC<{ + value: string; + children: React.ReactNode; + activeTab?: string; + setActiveTab?: (tab: string) => void; +}> = ({ value, children, activeTab, setActiveTab }) => { + const isActive = activeTab === value; + return ( + + ); +}; + +const TabsContent: React.FC<{ + value: string; + children: React.ReactNode; + activeTab?: string; + className?: string; +}> = ({ value, children, activeTab, className = '' }) => { + if (activeTab !== value) return null; + return
{children}
; +}; + +const Separator: React.FC<{ className?: string }> = ({ className = '' }) => { + return
; +}; + +const PlanComparison: React.FC = ({ plans, currentPlan, onUpgrade }) => { + return ( +
+ {Object.entries(plans).map(([key, plan]) => { + const isCurrentPlan = key === currentPlan; + const isPopular = plan.popular; + + return ( +
+ {isPopular && ( +
+ + + Más Popular + +
+ )} + + {isCurrentPlan && ( +
+ + + Activo + +
+ )} + +
+

{plan.name}

+

{plan.description}

+
+ + €{plan.monthly_price} + + /mes +
+ {plan.trial_available && !isCurrentPlan && ( +

+ 14 días de prueba gratis +

+ )} +
+ +
+
+ + + {plan.max_users === -1 ? 'Usuarios ilimitados' : `Hasta ${plan.max_users} usuarios`} + +
+
+ + + {plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`} + +
+
+ + + {plan.max_products === -1 ? 'Productos ilimitados' : `Hasta ${plan.max_products} productos`} + +
+ + {/* Key features */} +
+
+ + + {plan.features.inventory_management === 'basic' && 'Inventario básico'} + {plan.features.inventory_management === 'advanced' && 'Inventario avanzado'} + {plan.features.inventory_management === 'multi_location' && 'Inventario multi-locación'} + +
+
+ + + {plan.features.demand_prediction === 'basic' && 'Predicción básica'} + {plan.features.demand_prediction === 'ai_92_percent' && 'IA con 92% precisión'} + {plan.features.demand_prediction === 'ai_personalized' && 'IA personalizada'} + +
+ {plan.features.pos_integrated && ( +
+ + POS integrado +
+ )} + {plan.features.erp_integration && ( +
+ + Integración ERP +
+ )} + {plan.features.account_manager && ( +
+ + Manager dedicado +
+ )} +
+
+ + +
+ ); + })} +
+ ); +}; + +const SubscriptionPage: React.FC = () => { + const { user, tenant_id } = useAuth(); + const { currentTenant } = useBakeryStore(); + const toast = useToast(); + const [usageSummary, setUsageSummary] = useState(null); + const [availablePlans, setAvailablePlans] = useState(null); + const [loading, setLoading] = useState(true); + const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); + const [selectedPlan, setSelectedPlan] = useState(''); + const [upgrading, setUpgrading] = useState(false); + + useEffect(() => { + if (currentTenant?.id || tenant_id || isMockMode()) { + loadSubscriptionData(); + } + }, [currentTenant, tenant_id]); + + const loadSubscriptionData = async () => { + let tenantId = currentTenant?.id || tenant_id; + + // In mock mode, use the mock tenant ID if no real tenant is available + if (isMockMode() && !tenantId) { + tenantId = getMockSubscription().tenant_id; + console.log('🧪 Mock mode: Using mock tenant ID:', tenantId); + } + + console.log('📊 Loading subscription data for tenant:', tenantId, '| Mock mode:', isMockMode()); + + if (!tenantId) return; + + try { + setLoading(true); + const [usage, plans] = await Promise.all([ + subscriptionService.getUsageSummary(tenantId), + subscriptionService.getAvailablePlans() + ]); + + setUsageSummary(usage); + setAvailablePlans(plans); + } catch (error) { + console.error('Error loading subscription data:', error); + toast.error("No se pudo cargar la información de suscripción"); + } finally { + setLoading(false); + } + }; + + const handleUpgradeClick = (planKey: string) => { + setSelectedPlan(planKey); + setUpgradeDialogOpen(true); + }; + + const handleUpgradeConfirm = async () => { + let tenantId = currentTenant?.id || tenant_id; + + // In mock mode, use the mock tenant ID if no real tenant is available + if (isMockMode() && !tenantId) { + tenantId = getMockSubscription().tenant_id; + } + + if (!tenantId || !selectedPlan) return; + + try { + setUpgrading(true); + + const validation = await subscriptionService.validatePlanUpgrade( + tenantId, + selectedPlan + ); + + if (!validation.can_upgrade) { + toast.error(validation.reason); + return; + } + + const result = await subscriptionService.upgradePlan(tenantId, selectedPlan); + + if (result.success) { + toast.success(result.message); + + await loadSubscriptionData(); + setUpgradeDialogOpen(false); + setSelectedPlan(''); + } else { + throw new Error(result.message); + } + } catch (error) { + console.error('Error upgrading plan:', error); + toast.error("No se pudo actualizar el plan. Inténtalo de nuevo."); + } finally { + setUpgrading(false); + } + }; + + const getUsageColor = (percentage: number) => { + if (percentage >= 90) return 'text-red-600'; + if (percentage >= 75) return 'text-yellow-600'; + return 'text-green-600'; + }; + + if (loading) { + return ( +
+ +
+
+ +

Cargando información de suscripción...

+
+
+
+ ); + } + + if (!usageSummary || !availablePlans) { + return ( +
+ +
+
+ +
+

No se pudo cargar la información

+

Hubo un problema al cargar los datos de suscripción

+ +
+
+
+
+ ); + } + + const nextBillingDate = usageSummary.next_billing_date + ? new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES') + : 'N/A'; + + const trialEndsAt = usageSummary.trial_ends_at + ? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES') + : null; + + return ( +
+
+
+

Suscripción

+

+ Gestiona tu plan, facturación y límites de uso +

+
+
+ + + + Resumen + Uso Actual + Cambiar Plan + Facturación + + + + {/* Current Plan Overview */} + + +
+
+

+ Plan {subscriptionService.getPlanDisplayInfo(usageSummary.plan).name} + {usageSummary.plan === 'professional' && ( + + + Más Popular + + )} +

+

+ {subscriptionService.formatPrice(usageSummary.monthly_price)}/mes • + Estado: {usageSummary.status === 'active' ? 'Activo' : usageSummary.status} +

+
+
+
Próxima facturación
+
{nextBillingDate}
+
+
+
+ + {trialEndsAt && ( + + + Tu período de prueba gratuita termina el {trialEndsAt} + + )} + +
+
+ +
+ {usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit} +
+
Usuarios
+
+
+ +
+ {usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit} +
+
Ubicaciones
+
+
+ +
+ {usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit} +
+
Productos
+
+
+
+
+
+ + + {/* Usage Details */} +
+ + +

+ + Usuarios +

+
+ +
+
+ Usado + + {usageSummary.usage.users.current} de {usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit} + +
+ {!usageSummary.usage.users.unlimited && ( + + )} +
+
+
+ + + +

+ + Ubicaciones +

+
+ +
+
+ Usado + + {usageSummary.usage.locations.current} de {usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit} + +
+ {!usageSummary.usage.locations.unlimited && ( + + )} +
+
+
+ + + +

+ + Productos +

+
+ +
+
+ Usado + + {usageSummary.usage.products.current} de {usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit} + +
+ {!usageSummary.usage.products.unlimited && ( + + )} +
+
+
+
+
+ + +
+

Planes Disponibles

+ +
+
+ + + + +

+ + Información de Facturación +

+
+ +
+
+
Plan Actual
+
+ {subscriptionService.getPlanDisplayInfo(usageSummary.plan).name} +
+
+
+
Precio Mensual
+
+ {subscriptionService.formatPrice(usageSummary.monthly_price)} +
+
+
+
Próxima Facturación
+
+ + {nextBillingDate} +
+
+
+
Estado
+ + {usageSummary.status === 'active' ? 'Activo' : usageSummary.status} + +
+
+ + + +
+
Próximos Cobros
+
+ Se facturará {subscriptionService.formatPrice(usageSummary.monthly_price)} el {nextBillingDate} +
+
+
+
+
+
+ + {/* Upgrade Confirmation Modal */} + {upgradeDialogOpen && ( + setUpgradeDialogOpen(false)}> +
+
+

Confirmar Cambio de Plan

+ +
+ +

+ ¿Estás seguro de que quieres cambiar al plan{' '} + {selectedPlan && availablePlans?.plans[selectedPlan]?.name}? +

+ + {selectedPlan && availablePlans?.plans[selectedPlan] && ( +
+
+
+ Plan actual: + {subscriptionService.getPlanDisplayInfo(usageSummary.plan).name} +
+
+ Nuevo plan: + {availablePlans.plans[selectedPlan].name} +
+
+ Nuevo precio: + {subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes +
+
+
+ )} + +
+ + +
+
+
+ )} +
+ ); +}; + +export default SubscriptionPage; \ No newline at end of file diff --git a/frontend/src/pages/app/settings/subscription/index.ts b/frontend/src/pages/app/settings/subscription/index.ts new file mode 100644 index 00000000..8f015b92 --- /dev/null +++ b/frontend/src/pages/app/settings/subscription/index.ts @@ -0,0 +1 @@ +export { default } from './SubscriptionPage'; \ No newline at end of file diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 44fcf843..b843277a 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -4,4 +4,5 @@ export * from './public'; // App pages export { default as DashboardPage } from './app/DashboardPage'; export * from './app/operations'; -export * from './app/analytics'; \ No newline at end of file +export * from './app/analytics'; +export * from './app/settings'; \ No newline at end of file diff --git a/frontend/src/pages/public/LandingPage.tsx b/frontend/src/pages/public/LandingPage.tsx index bb1918b8..7533685e 100644 --- a/frontend/src/pages/public/LandingPage.tsx +++ b/frontend/src/pages/public/LandingPage.tsx @@ -459,116 +459,312 @@ const LandingPage: React.FC = () => {
{/* Starter Plan */} -
-

Starter

-

Perfecto para panaderías pequeñas

-
- €49 - /mes -
-
-
- - Hasta 50 productos -
-
- - Predicción básica de demanda -
-
- - Control de inventario -
-
- - Reportes básicos -
-
- - Soporte por email +
+
+
+
-
{/* Professional Plan - Highlighted */} -
+
- - Más Popular - -
-

Professional

-

Para panaderías en crecimiento

-
- €149 - /mes -
-
-
- - Productos ilimitados -
-
- - IA avanzada con 92% precisión -
-
- - Gestión completa de producción -
-
- - POS integrado -
-
- - Analytics avanzado -
-
- - Soporte prioritario 24/7 +
+ ⭐ Más Popular
-
{/* Enterprise Plan */} -
-

Enterprise

-

Para cadenas y grandes operaciones

-
- €399 - /mes -
-
-
- - Multi-locación ilimitada -
-
- - IA personalizada por ubicación -
-
- - API personalizada -
-
- - Integración ERPs -
-
- - Manager dedicado +
+
+
+
-
diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx index 8c0b42ed..10f73322 100644 --- a/frontend/src/router/AppRouter.tsx +++ b/frontend/src/router/AppRouter.tsx @@ -35,6 +35,7 @@ const BakeryConfigPage = React.lazy(() => import('../pages/app/settings/bakery-c const SystemSettingsPage = React.lazy(() => import('../pages/app/settings/system/SystemSettingsPage')); const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage')); const TrainingPage = React.lazy(() => import('../pages/app/settings/training/TrainingPage')); +const SubscriptionPage = React.lazy(() => import('../pages/app/settings/subscription/SubscriptionPage')); // Data pages const WeatherPage = React.lazy(() => import('../pages/app/data/weather/WeatherPage')); @@ -265,6 +266,16 @@ export const AppRouter: React.FC = () => { } /> + + + + + + } + /> {/* Data Routes */} ; +} + +export interface UsageSummary { + plan: string; + monthly_price: number; + status: string; + usage: { + users: { + current: number; + limit: number; + unlimited: boolean; + usage_percentage: number; + }; + locations: { + current: number; + limit: number; + unlimited: boolean; + usage_percentage: number; + }; + products: { + current: number; + limit: number; + unlimited: boolean; + usage_percentage: number; + }; + }; + features: Record; + next_billing_date?: string; + trial_ends_at?: string; +} + +export interface LimitCheckResult { + can_add: boolean; + current_count?: number; + max_allowed?: number; + reason: string; +} + +export interface FeatureCheckResult { + has_feature: boolean; + feature_value?: any; + plan: string; + reason: string; +} + +export interface PlanUpgradeValidation { + can_upgrade: boolean; + current_plan?: string; + new_plan?: string; + price_change?: number; + new_features?: Record; + reason: string; +} + +export interface AvailablePlan { + name: string; + description: string; + monthly_price: number; + max_users: number; + max_locations: number; + max_products: number; + features: Record; + trial_available: boolean; + popular?: boolean; + contact_sales?: boolean; +} + +export interface AvailablePlans { + plans: Record; +} + +export interface BillingHistoryItem { + id: string; + date: string; + amount: number; + status: 'paid' | 'pending' | 'failed'; + description: string; +} + +export interface SubscriptionData { + id: string; + tenant_id: string; + plan: string; + status: string; + monthly_price: number; + currency: string; + billing_cycle: string; + current_period_start: string; + current_period_end: string; + next_billing_date: string; + trial_ends_at?: string | null; + canceled_at?: string | null; + created_at: string; + updated_at: string; + max_users: number; + max_locations: number; + max_products: number; + features: Record; + usage: { + users: number; + locations: number; + products: number; + storage_gb: number; + api_calls_month: number; + reports_generated: number; + }; + billing_history: BillingHistoryItem[]; +} + +class SubscriptionService { + private apiClient: ApiClient; + + constructor() { + this.apiClient = new ApiClient(); + } + + /** + * Get current subscription limits for a tenant + */ + async getSubscriptionLimits(tenantId: string): Promise { + if (isMockMode()) { + const mockSub = getMockSubscription(); + return { + plan: mockSub.plan, + max_users: mockSub.max_users, + max_locations: mockSub.max_locations, + max_products: mockSub.max_products, + features: mockSub.features + }; + } + + const response = await this.apiClient.get(`/subscriptions/${tenantId}/limits`); + return response.data; + } + + /** + * Get usage summary vs limits for a tenant + */ + async getUsageSummary(tenantId: string): Promise { + if (isMockMode()) { + console.log('🧪 Mock mode: Returning usage summary for tenant:', tenantId); + const mockSub = getMockSubscription(); + return { + plan: mockSub.plan, + monthly_price: mockSub.monthly_price, + status: mockSub.status, + usage: { + users: { + current: mockSub.usage.users, + limit: mockSub.max_users, + unlimited: mockSub.max_users === -1, + usage_percentage: mockSub.max_users === -1 ? 0 : Math.round((mockSub.usage.users / mockSub.max_users) * 100) + }, + locations: { + current: mockSub.usage.locations, + limit: mockSub.max_locations, + unlimited: mockSub.max_locations === -1, + usage_percentage: mockSub.max_locations === -1 ? 0 : Math.round((mockSub.usage.locations / mockSub.max_locations) * 100) + }, + products: { + current: mockSub.usage.products, + limit: mockSub.max_products, + unlimited: mockSub.max_products === -1, + usage_percentage: mockSub.max_products === -1 ? 0 : Math.round((mockSub.usage.products / mockSub.max_products) * 100) + } + }, + features: mockSub.features, + next_billing_date: mockSub.next_billing_date, + trial_ends_at: mockSub.trial_ends_at + }; + } + + const response = await this.apiClient.get(`/subscriptions/${tenantId}/usage`); + return response.data; + } + + /** + * Check if tenant can add another location + */ + async canAddLocation(tenantId: string): Promise { + if (isMockMode()) { + const mockSub = getMockSubscription(); + const canAdd = mockSub.max_locations === -1 || mockSub.usage.locations < mockSub.max_locations; + return { + can_add: canAdd, + current_count: mockSub.usage.locations, + max_allowed: mockSub.max_locations, + reason: canAdd ? 'Can add more locations' : 'Location limit reached. Upgrade plan to add more locations.' + }; + } + + const response = await this.apiClient.get(`/subscriptions/${tenantId}/can-add-location`); + return response.data; + } + + /** + * Check if tenant can add another product + */ + async canAddProduct(tenantId: string): Promise { + if (isMockMode()) { + const mockSub = getMockSubscription(); + const canAdd = mockSub.max_products === -1 || mockSub.usage.products < mockSub.max_products; + return { + can_add: canAdd, + current_count: mockSub.usage.products, + max_allowed: mockSub.max_products, + reason: canAdd ? 'Can add more products' : 'Product limit reached. Upgrade plan to add more products.' + }; + } + + const response = await this.apiClient.get(`/subscriptions/${tenantId}/can-add-product`); + return response.data; + } + + /** + * Check if tenant can add another user/member + */ + async canAddUser(tenantId: string): Promise { + if (isMockMode()) { + const mockSub = getMockSubscription(); + const canAdd = mockSub.max_users === -1 || mockSub.usage.users < mockSub.max_users; + return { + can_add: canAdd, + current_count: mockSub.usage.users, + max_allowed: mockSub.max_users, + reason: canAdd ? 'Can add more users' : 'User limit reached. Upgrade plan to add more users.' + }; + } + + const response = await this.apiClient.get(`/subscriptions/${tenantId}/can-add-user`); + return response.data; + } + + /** + * Check if tenant has access to a specific feature + */ + async hasFeature(tenantId: string, feature: string): Promise { + if (isMockMode()) { + const mockSub = getMockSubscription(); + const hasFeature = feature in mockSub.features; + return { + has_feature: hasFeature, + feature_value: hasFeature ? mockSub.features[feature] : null, + plan: mockSub.plan, + reason: hasFeature ? `Feature ${feature} is available in ${mockSub.plan} plan` : `Feature ${feature} is not available in ${mockSub.plan} plan` + }; + } + + const response = await this.apiClient.get(`/subscriptions/${tenantId}/features/${feature}`); + return response.data; + } + + /** + * Validate if tenant can upgrade to a new plan + */ + async validatePlanUpgrade(tenantId: string, newPlan: string): Promise { + const response = await this.apiClient.get(`/subscriptions/${tenantId}/validate-upgrade/${newPlan}`); + return response.data; + } + + /** + * Upgrade subscription plan for a tenant + */ + async upgradePlan(tenantId: string, newPlan: string): Promise<{ + success: boolean; + message: string; + validation: PlanUpgradeValidation; + }> { + const response = await this.apiClient.post(`/subscriptions/${tenantId}/upgrade`, null, { + params: { new_plan: newPlan } + }); + return response.data; + } + + /** + * Get full subscription data including billing history for admin@bakery.com + */ + async getSubscriptionData(tenantId: string): Promise { + if (isMockMode()) { + return getMockSubscription(); + } + + const response = await this.apiClient.get(`/subscriptions/${tenantId}/details`); + return response.data; + } + + /** + * Get billing history for a subscription + */ + async getBillingHistory(tenantId: string): Promise { + if (isMockMode()) { + return getMockSubscription().billing_history; + } + + const response = await this.apiClient.get(`/subscriptions/${tenantId}/billing-history`); + return response.data; + } + + /** + * Get all available subscription plans with features and pricing + */ + async getAvailablePlans(): Promise { + if (isMockMode()) { + return { + plans: { + starter: { + name: 'Starter', + description: 'Perfecto para panaderías pequeñas que están comenzando', + monthly_price: 49.0, + max_users: 5, + max_locations: 1, + max_products: 50, + features: { + inventory_management: 'basic', + demand_prediction: 'basic', + production_reports: 'basic', + analytics: 'basic', + support: 'email', + trial_days: 14, + locations: '1_location' + }, + trial_available: true + }, + professional: { + name: 'Professional', + description: 'Para panaderías en crecimiento que necesitan más control', + monthly_price: 129.0, + max_users: 15, + max_locations: 2, + max_products: -1, + features: { + inventory_management: 'advanced', + demand_prediction: 'ai_92_percent', + production_management: 'complete', + pos_integrated: true, + logistics: 'basic', + analytics: 'advanced', + support: 'priority_24_7', + trial_days: 14, + locations: '1_2_locations' + }, + trial_available: true, + popular: true + }, + enterprise: { + name: 'Enterprise', + description: 'Para cadenas de panaderías con necesidades avanzadas', + monthly_price: 399.0, + max_users: -1, + max_locations: -1, + max_products: -1, + features: { + inventory_management: 'multi_location', + demand_prediction: 'ai_personalized', + production_optimization: 'capacity', + erp_integration: true, + logistics: 'advanced', + analytics: 'predictive', + api_access: 'personalized', + account_manager: true, + demo: 'personalized', + locations: 'unlimited_obradores' + }, + trial_available: false, + contact_sales: true + } + } + }; + } + + const response = await this.apiClient.get('/plans/available'); + return response.data; + } + + /** + * Helper method to check if a feature is enabled for current tenant + */ + async isFeatureEnabled(tenantId: string, feature: string): Promise { + try { + const result = await this.hasFeature(tenantId, feature); + return result.has_feature; + } catch (error) { + console.error(`Error checking feature ${feature}:`, error); + return false; + } + } + + /** + * Helper method to get feature level (basic, advanced, etc.) + */ + async getFeatureLevel(tenantId: string, feature: string): Promise { + try { + const result = await this.hasFeature(tenantId, feature); + return result.feature_value || null; + } catch (error) { + console.error(`Error getting feature level for ${feature}:`, error); + return null; + } + } + + /** + * Helper method to check if usage is approaching limits + */ + async isUsageNearLimit(tenantId: string, threshold: number = 80): Promise<{ + users: boolean; + locations: boolean; + products: boolean; + }> { + try { + const usage = await this.getUsageSummary(tenantId); + + return { + users: !usage.usage.users.unlimited && usage.usage.users.usage_percentage >= threshold, + locations: !usage.usage.locations.unlimited && usage.usage.locations.usage_percentage >= threshold, + products: !usage.usage.products.unlimited && usage.usage.products.usage_percentage >= threshold, + }; + } catch (error) { + console.error('Error checking usage limits:', error); + return { users: false, locations: false, products: false }; + } + } + + /** + * Helper method to format pricing for display + */ + formatPrice(price: number, currency: string = 'EUR'): string { + return new Intl.NumberFormat('es-ES', { + style: 'currency', + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(price); + } + + /** + * Helper method to get plan display information + */ + getPlanDisplayInfo(plan: string): { + name: string; + color: string; + badge?: string; + } { + const planInfo = { + starter: { + name: 'Starter', + color: 'blue', + }, + professional: { + name: 'Professional', + color: 'purple', + badge: 'Más Popular' + }, + enterprise: { + name: 'Enterprise', + color: 'gold', + } + }; + + return planInfo[plan as keyof typeof planInfo] || { + name: plan, + color: 'gray' + }; + } +} + +export const subscriptionService = new SubscriptionService(); +export default subscriptionService; \ No newline at end of file diff --git a/services/tenant/app/api/subscriptions.py b/services/tenant/app/api/subscriptions.py new file mode 100644 index 00000000..3b3dbe14 --- /dev/null +++ b/services/tenant/app/api/subscriptions.py @@ -0,0 +1,330 @@ +""" +Subscription API endpoints for plan limits and feature validation +""" + +import structlog +from fastapi import APIRouter, Depends, HTTPException, status, Path, Query +from typing import List, Dict, Any, Optional +from uuid import UUID + +from app.services.subscription_limit_service import SubscriptionLimitService +from app.repositories import SubscriptionRepository +from app.models.tenants import Subscription +from shared.auth.decorators import get_current_user_dep, require_admin_role_dep +from shared.database.base import create_database_manager +from shared.monitoring.metrics import track_endpoint_metrics + +logger = structlog.get_logger() +router = APIRouter() + +# Dependency injection for subscription limit service +def get_subscription_limit_service(): + try: + from app.core.config import settings + database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") + return SubscriptionLimitService(database_manager) + except Exception as e: + logger.error("Failed to create subscription limit service", error=str(e)) + raise HTTPException(status_code=500, detail="Service initialization failed") + +def get_subscription_repository(): + try: + from app.core.config import settings + database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") + # This would need to be properly initialized with session + # For now, we'll use the service pattern + return None + except Exception as e: + logger.error("Failed to create subscription repository", error=str(e)) + raise HTTPException(status_code=500, detail="Repository initialization failed") + +@router.get("/subscriptions/{tenant_id}/limits") +@track_endpoint_metrics("subscription_get_limits") +async def get_subscription_limits( + tenant_id: UUID = Path(..., description="Tenant ID"), + current_user: Dict[str, Any] = Depends(get_current_user_dep), + limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) +): + """Get current subscription limits for a tenant""" + + try: + # TODO: Add access control - verify user has access to tenant + limits = await limit_service.get_tenant_subscription_limits(str(tenant_id)) + return limits + + except Exception as e: + logger.error("Failed to get subscription limits", + tenant_id=str(tenant_id), + error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get subscription limits" + ) + +@router.get("/subscriptions/{tenant_id}/usage") +@track_endpoint_metrics("subscription_get_usage") +async def get_usage_summary( + tenant_id: UUID = Path(..., description="Tenant ID"), + current_user: Dict[str, Any] = Depends(get_current_user_dep), + limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) +): + """Get usage summary vs limits for a tenant""" + + try: + # TODO: Add access control - verify user has access to tenant + usage = await limit_service.get_usage_summary(str(tenant_id)) + return usage + + except Exception as e: + logger.error("Failed to get usage summary", + tenant_id=str(tenant_id), + error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get usage summary" + ) + +@router.get("/subscriptions/{tenant_id}/can-add-location") +@track_endpoint_metrics("subscription_check_location_limit") +async def can_add_location( + tenant_id: UUID = Path(..., description="Tenant ID"), + current_user: Dict[str, Any] = Depends(get_current_user_dep), + limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) +): + """Check if tenant can add another location""" + + try: + # TODO: Add access control - verify user has access to tenant + result = await limit_service.can_add_location(str(tenant_id)) + return result + + except Exception as e: + logger.error("Failed to check location limits", + tenant_id=str(tenant_id), + error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to check location limits" + ) + +@router.get("/subscriptions/{tenant_id}/can-add-product") +@track_endpoint_metrics("subscription_check_product_limit") +async def can_add_product( + tenant_id: UUID = Path(..., description="Tenant ID"), + current_user: Dict[str, Any] = Depends(get_current_user_dep), + limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) +): + """Check if tenant can add another product""" + + try: + # TODO: Add access control - verify user has access to tenant + result = await limit_service.can_add_product(str(tenant_id)) + return result + + except Exception as e: + logger.error("Failed to check product limits", + tenant_id=str(tenant_id), + error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to check product limits" + ) + +@router.get("/subscriptions/{tenant_id}/can-add-user") +@track_endpoint_metrics("subscription_check_user_limit") +async def can_add_user( + tenant_id: UUID = Path(..., description="Tenant ID"), + current_user: Dict[str, Any] = Depends(get_current_user_dep), + limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) +): + """Check if tenant can add another user/member""" + + try: + # TODO: Add access control - verify user has access to tenant + result = await limit_service.can_add_user(str(tenant_id)) + return result + + except Exception as e: + logger.error("Failed to check user limits", + tenant_id=str(tenant_id), + error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to check user limits" + ) + +@router.get("/subscriptions/{tenant_id}/features/{feature}") +@track_endpoint_metrics("subscription_check_feature") +async def has_feature( + tenant_id: UUID = Path(..., description="Tenant ID"), + feature: str = Path(..., description="Feature name"), + current_user: Dict[str, Any] = Depends(get_current_user_dep), + limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) +): + """Check if tenant has access to a specific feature""" + + try: + # TODO: Add access control - verify user has access to tenant + result = await limit_service.has_feature(str(tenant_id), feature) + return result + + except Exception as e: + logger.error("Failed to check feature access", + tenant_id=str(tenant_id), + feature=feature, + error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to check feature access" + ) + +@router.get("/subscriptions/{tenant_id}/validate-upgrade/{new_plan}") +@track_endpoint_metrics("subscription_validate_upgrade") +async def validate_plan_upgrade( + tenant_id: UUID = Path(..., description="Tenant ID"), + new_plan: str = Path(..., description="New plan name"), + current_user: Dict[str, Any] = Depends(get_current_user_dep), + limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) +): + """Validate if tenant can upgrade to a new plan""" + + try: + # TODO: Add access control - verify user has admin access to tenant + result = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan) + return result + + except Exception as e: + logger.error("Failed to validate plan upgrade", + tenant_id=str(tenant_id), + new_plan=new_plan, + error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to validate plan upgrade" + ) + +@router.post("/subscriptions/{tenant_id}/upgrade") +@track_endpoint_metrics("subscription_upgrade_plan") +async def upgrade_subscription_plan( + tenant_id: UUID = Path(..., description="Tenant ID"), + new_plan: str = Query(..., description="New plan name"), + current_user: Dict[str, Any] = Depends(get_current_user_dep), + limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) +): + """Upgrade subscription plan for a tenant""" + + try: + # TODO: Add access control - verify user is owner/admin of tenant + + # First validate the upgrade + validation = await limit_service.validate_plan_upgrade(str(tenant_id), new_plan) + if not validation.get("can_upgrade", False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=validation.get("reason", "Cannot upgrade to this plan") + ) + + # TODO: Implement actual plan upgrade logic + # This would involve: + # 1. Update subscription in database + # 2. Process payment changes + # 3. Update billing cycle + # 4. Send notifications + + return { + "success": True, + "message": f"Plan upgrade to {new_plan} initiated", + "validation": validation + } + + except HTTPException: + raise + except Exception as e: + logger.error("Failed to upgrade subscription plan", + tenant_id=str(tenant_id), + new_plan=new_plan, + error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to upgrade subscription plan" + ) + +@router.get("/plans/available") +@track_endpoint_metrics("subscription_get_available_plans") +async def get_available_plans(): + """Get all available subscription plans with features and pricing""" + + try: + # This could be moved to a config service or database + plans = { + "starter": { + "name": "Starter", + "description": "Ideal para panaderías pequeñas o nuevas", + "monthly_price": 49.0, + "max_users": 5, + "max_locations": 1, + "max_products": 50, + "features": { + "inventory_management": "basic", + "demand_prediction": "basic", + "production_reports": "basic", + "analytics": "basic", + "support": "email", + "trial_days": 14, + "locations": "1_location" + }, + "trial_available": True + }, + "professional": { + "name": "Professional", + "description": "Ideal para panaderías y cadenas en crecimiento", + "monthly_price": 129.0, + "max_users": 15, + "max_locations": 2, + "max_products": -1, # Unlimited + "features": { + "inventory_management": "advanced", + "demand_prediction": "ai_92_percent", + "production_management": "complete", + "pos_integrated": True, + "logistics": "basic", + "analytics": "advanced", + "support": "priority_24_7", + "trial_days": 14, + "locations": "1_2_locations" + }, + "trial_available": True, + "popular": True + }, + "enterprise": { + "name": "Enterprise", + "description": "Ideal para cadenas con obradores centrales", + "monthly_price": 399.0, + "max_users": -1, # Unlimited + "max_locations": -1, # Unlimited + "max_products": -1, # Unlimited + "features": { + "inventory_management": "multi_location", + "demand_prediction": "ai_personalized", + "production_optimization": "capacity", + "erp_integration": True, + "logistics": "advanced", + "analytics": "predictive", + "api_access": "personalized", + "account_manager": True, + "demo": "personalized", + "locations": "unlimited_obradores" + }, + "trial_available": False, + "contact_sales": True + } + } + + return {"plans": plans} + + except Exception as e: + logger.error("Failed to get available plans", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get available plans" + ) \ No newline at end of file diff --git a/services/tenant/app/models/tenants.py b/services/tenant/app/models/tenants.py index 6eebe913..06cd104c 100644 --- a/services/tenant/app/models/tenants.py +++ b/services/tenant/app/models/tenants.py @@ -4,7 +4,7 @@ Tenant models for bakery management - FIXED Removed cross-service User relationship to eliminate circular dependencies """ -from sqlalchemy import Column, String, Boolean, DateTime, Float, ForeignKey, Text, Integer +from sqlalchemy import Column, String, Boolean, DateTime, Float, ForeignKey, Text, Integer, JSON from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from datetime import datetime, timezone @@ -35,7 +35,7 @@ class Tenant(Base): # Status is_active = Column(Boolean, default=True) - subscription_tier = Column(String(50), default="basic") + subscription_tier = Column(String(50), default="starter") # ML status model_trained = Column(Boolean, default=False) @@ -92,7 +92,7 @@ class Subscription(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False) - plan = Column(String(50), default="basic") # basic, professional, enterprise + plan = Column(String(50), default="starter") # starter, professional, enterprise status = Column(String(50), default="active") # active, suspended, cancelled # Billing @@ -102,10 +102,13 @@ class Subscription(Base): trial_ends_at = Column(DateTime(timezone=True)) # Limits - max_users = Column(Integer, default=1) + max_users = Column(Integer, default=5) max_locations = Column(Integer, default=1) max_products = Column(Integer, default=50) + # Features - Store plan features as JSON + features = Column(JSON) + # Timestamps created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/services/tenant/app/repositories/subscription_repository.py b/services/tenant/app/repositories/subscription_repository.py index 5a589394..d33e7628 100644 --- a/services/tenant/app/repositories/subscription_repository.py +++ b/services/tenant/app/repositories/subscription_repository.py @@ -133,7 +133,7 @@ class SubscriptionRepository(TenantBaseRepository): ) -> Optional[Subscription]: """Update subscription plan and pricing""" try: - valid_plans = ["basic", "professional", "enterprise"] + valid_plans = ["starter", "professional", "enterprise"] if new_plan not in valid_plans: raise ValidationError(f"Invalid plan. Must be one of: {valid_plans}") @@ -147,6 +147,7 @@ class SubscriptionRepository(TenantBaseRepository): "max_users": plan_config["max_users"], "max_locations": plan_config["max_locations"], "max_products": plan_config["max_products"], + "features": plan_config.get("features", {}), "updated_at": datetime.utcnow() } @@ -397,24 +398,56 @@ class SubscriptionRepository(TenantBaseRepository): def _get_plan_configuration(self, plan: str) -> Dict[str, Any]: """Get configuration for a subscription plan""" plan_configs = { - "basic": { - "monthly_price": 29.99, - "max_users": 2, + "starter": { + "monthly_price": 49.0, + "max_users": 5, # Reasonable for small bakeries "max_locations": 1, - "max_products": 50 + "max_products": 50, + "features": { + "inventory_management": "basic", + "demand_prediction": "basic", + "production_reports": "basic", + "analytics": "basic", + "support": "email", + "trial_days": 14, + "locations": "1_location" + } }, "professional": { - "monthly_price": 79.99, - "max_users": 10, - "max_locations": 3, - "max_products": 200 + "monthly_price": 129.0, + "max_users": 15, # Good for growing bakeries + "max_locations": 2, + "max_products": -1, # Unlimited products + "features": { + "inventory_management": "advanced", + "demand_prediction": "ai_92_percent", + "production_management": "complete", + "pos_integrated": True, + "logistics": "basic", + "analytics": "advanced", + "support": "priority_24_7", + "trial_days": 14, + "locations": "1_2_locations" + } }, "enterprise": { - "monthly_price": 199.99, - "max_users": 50, - "max_locations": 10, - "max_products": 1000 + "monthly_price": 399.0, + "max_users": -1, # Unlimited users + "max_locations": -1, # Unlimited locations + "max_products": -1, # Unlimited products + "features": { + "inventory_management": "multi_location", + "demand_prediction": "ai_personalized", + "production_optimization": "capacity", + "erp_integration": True, + "logistics": "advanced", + "analytics": "predictive", + "api_access": "personalized", + "account_manager": True, + "demo": "personalized", + "locations": "unlimited_obradores" + } } } - return plan_configs.get(plan, plan_configs["basic"]) \ No newline at end of file + return plan_configs.get(plan, plan_configs["starter"]) \ No newline at end of file diff --git a/services/tenant/app/services/subscription_limit_service.py b/services/tenant/app/services/subscription_limit_service.py new file mode 100644 index 00000000..f4921036 --- /dev/null +++ b/services/tenant/app/services/subscription_limit_service.py @@ -0,0 +1,332 @@ +""" +Subscription Limit Service +Service for validating tenant actions against subscription limits and features +""" + +import structlog +from typing import Dict, Any, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import HTTPException, status + +from app.repositories import SubscriptionRepository, TenantRepository, TenantMemberRepository +from app.models.tenants import Subscription, Tenant, TenantMember +from shared.database.exceptions import DatabaseError +from shared.database.base import create_database_manager + +logger = structlog.get_logger() + + +class SubscriptionLimitService: + """Service for validating subscription limits and features""" + + def __init__(self, database_manager=None): + self.database_manager = database_manager or create_database_manager() + + async def _init_repositories(self, session): + """Initialize repositories with session""" + self.subscription_repo = SubscriptionRepository(Subscription, session) + self.tenant_repo = TenantRepository(Tenant, session) + self.member_repo = TenantMemberRepository(TenantMember, session) + return { + 'subscription': self.subscription_repo, + 'tenant': self.tenant_repo, + 'member': self.member_repo + } + + async def get_tenant_subscription_limits(self, tenant_id: str) -> Dict[str, Any]: + """Get current subscription limits for a tenant""" + try: + async with self.database_manager.get_session() as db_session: + await self._init_repositories(db_session) + + subscription = await self.subscription_repo.get_active_subscription(tenant_id) + if not subscription: + # Return basic limits if no subscription + return { + "plan": "starter", + "max_users": 5, + "max_locations": 1, + "max_products": 50, + "features": { + "inventory_management": "basic", + "demand_prediction": "basic", + "production_reports": "basic", + "analytics": "basic", + "support": "email" + } + } + + return { + "plan": subscription.plan, + "max_users": subscription.max_users, + "max_locations": subscription.max_locations, + "max_products": subscription.max_products, + "features": subscription.features or {} + } + + except Exception as e: + logger.error("Failed to get subscription limits", + tenant_id=tenant_id, + error=str(e)) + # Return basic limits on error + return { + "plan": "starter", + "max_users": 5, + "max_locations": 1, + "max_products": 50, + "features": {} + } + + async def can_add_location(self, tenant_id: str) -> Dict[str, Any]: + """Check if tenant can add another location""" + try: + async with self.database_manager.get_session() as db_session: + await self._init_repositories(db_session) + + # Get subscription limits + subscription = await self.subscription_repo.get_active_subscription(tenant_id) + if not subscription: + return {"can_add": False, "reason": "No active subscription"} + + # Check if unlimited locations (-1) + if subscription.max_locations == -1: + return {"can_add": True, "reason": "Unlimited locations allowed"} + + # Count current locations (this would need to be implemented based on your location model) + # For now, we'll assume 1 location per tenant as default + current_locations = 1 # TODO: Implement actual location count + + can_add = current_locations < subscription.max_locations + return { + "can_add": can_add, + "current_count": current_locations, + "max_allowed": subscription.max_locations, + "reason": "Within limits" if can_add else f"Maximum {subscription.max_locations} locations allowed for {subscription.plan} plan" + } + + except Exception as e: + logger.error("Failed to check location limits", + tenant_id=tenant_id, + error=str(e)) + return {"can_add": False, "reason": "Error checking limits"} + + async def can_add_product(self, tenant_id: str) -> Dict[str, Any]: + """Check if tenant can add another product""" + try: + async with self.database_manager.get_session() as db_session: + await self._init_repositories(db_session) + + # Get subscription limits + subscription = await self.subscription_repo.get_active_subscription(tenant_id) + if not subscription: + return {"can_add": False, "reason": "No active subscription"} + + # Check if unlimited products (-1) + if subscription.max_products == -1: + return {"can_add": True, "reason": "Unlimited products allowed"} + + # Count current products (this would need to be implemented based on your product model) + # For now, we'll return a placeholder + current_products = 0 # TODO: Implement actual product count + + can_add = current_products < subscription.max_products + return { + "can_add": can_add, + "current_count": current_products, + "max_allowed": subscription.max_products, + "reason": "Within limits" if can_add else f"Maximum {subscription.max_products} products allowed for {subscription.plan} plan" + } + + except Exception as e: + logger.error("Failed to check product limits", + tenant_id=tenant_id, + error=str(e)) + return {"can_add": False, "reason": "Error checking limits"} + + async def can_add_user(self, tenant_id: str) -> Dict[str, Any]: + """Check if tenant can add another user/member""" + try: + async with self.database_manager.get_session() as db_session: + await self._init_repositories(db_session) + + # Get subscription limits + subscription = await self.subscription_repo.get_active_subscription(tenant_id) + if not subscription: + return {"can_add": False, "reason": "No active subscription"} + + # Check if unlimited users (-1) + if subscription.max_users == -1: + return {"can_add": True, "reason": "Unlimited users allowed"} + + # Count current active members + members = await self.member_repo.get_tenant_members(tenant_id, active_only=True) + current_users = len(members) + + can_add = current_users < subscription.max_users + return { + "can_add": can_add, + "current_count": current_users, + "max_allowed": subscription.max_users, + "reason": "Within limits" if can_add else f"Maximum {subscription.max_users} users allowed for {subscription.plan} plan" + } + + except Exception as e: + logger.error("Failed to check user limits", + tenant_id=tenant_id, + error=str(e)) + return {"can_add": False, "reason": "Error checking limits"} + + async def has_feature(self, tenant_id: str, feature: str) -> Dict[str, Any]: + """Check if tenant has access to a specific feature""" + try: + async with self.database_manager.get_session() as db_session: + await self._init_repositories(db_session) + + subscription = await self.subscription_repo.get_active_subscription(tenant_id) + if not subscription: + return {"has_feature": False, "reason": "No active subscription"} + + features = subscription.features or {} + has_feature = feature in features + + return { + "has_feature": has_feature, + "feature_value": features.get(feature), + "plan": subscription.plan, + "reason": "Feature available" if has_feature else f"Feature '{feature}' not available in {subscription.plan} plan" + } + + except Exception as e: + logger.error("Failed to check feature access", + tenant_id=tenant_id, + feature=feature, + error=str(e)) + return {"has_feature": False, "reason": "Error checking feature access"} + + async def get_feature_level(self, tenant_id: str, feature: str) -> Optional[str]: + """Get the level/type of a feature for a tenant""" + try: + async with self.database_manager.get_session() as db_session: + await self._init_repositories(db_session) + + subscription = await self.subscription_repo.get_active_subscription(tenant_id) + if not subscription: + return None + + features = subscription.features or {} + return features.get(feature) + + except Exception as e: + logger.error("Failed to get feature level", + tenant_id=tenant_id, + feature=feature, + error=str(e)) + return None + + async def validate_plan_upgrade(self, tenant_id: str, new_plan: str) -> Dict[str, Any]: + """Validate if a tenant can upgrade to a new plan""" + try: + async with self.database_manager.get_session() as db_session: + await self._init_repositories(db_session) + + # Get current subscription + current_subscription = await self.subscription_repo.get_active_subscription(tenant_id) + if not current_subscription: + return {"can_upgrade": True, "reason": "No current subscription, can start with any plan"} + + # Define plan hierarchy + plan_hierarchy = {"starter": 1, "professional": 2, "enterprise": 3} + + current_level = plan_hierarchy.get(current_subscription.plan, 0) + new_level = plan_hierarchy.get(new_plan, 0) + + if new_level == 0: + return {"can_upgrade": False, "reason": f"Invalid plan: {new_plan}"} + + # Check current usage against new plan limits + from app.repositories.subscription_repository import SubscriptionRepository + temp_repo = SubscriptionRepository(Subscription, db_session) + new_plan_config = temp_repo._get_plan_configuration(new_plan) + + # Check if current usage fits new plan + members = await self.member_repo.get_tenant_members(tenant_id, active_only=True) + current_users = len(members) + + if new_plan_config["max_users"] != -1 and current_users > new_plan_config["max_users"]: + return { + "can_upgrade": False, + "reason": f"Current usage ({current_users} users) exceeds {new_plan} plan limits ({new_plan_config['max_users']} users)" + } + + return { + "can_upgrade": True, + "current_plan": current_subscription.plan, + "new_plan": new_plan, + "price_change": new_plan_config["monthly_price"] - current_subscription.monthly_price, + "new_features": new_plan_config.get("features", {}), + "reason": "Upgrade validation successful" + } + + except Exception as e: + logger.error("Failed to validate plan upgrade", + tenant_id=tenant_id, + new_plan=new_plan, + error=str(e)) + return {"can_upgrade": False, "reason": "Error validating upgrade"} + + async def get_usage_summary(self, tenant_id: str) -> Dict[str, Any]: + """Get a summary of current usage vs limits for a tenant""" + try: + async with self.database_manager.get_session() as db_session: + await self._init_repositories(db_session) + + subscription = await self.subscription_repo.get_active_subscription(tenant_id) + if not subscription: + return {"error": "No active subscription"} + + # Get current usage + members = await self.member_repo.get_tenant_members(tenant_id, active_only=True) + current_users = len(members) + + # TODO: Implement actual location and product counts + current_locations = 1 + current_products = 0 + + return { + "plan": subscription.plan, + "monthly_price": subscription.monthly_price, + "status": subscription.status, + "usage": { + "users": { + "current": current_users, + "limit": subscription.max_users, + "unlimited": subscription.max_users == -1, + "usage_percentage": 0 if subscription.max_users == -1 else (current_users / subscription.max_users) * 100 + }, + "locations": { + "current": current_locations, + "limit": subscription.max_locations, + "unlimited": subscription.max_locations == -1, + "usage_percentage": 0 if subscription.max_locations == -1 else (current_locations / subscription.max_locations) * 100 + }, + "products": { + "current": current_products, + "limit": subscription.max_products, + "unlimited": subscription.max_products == -1, + "usage_percentage": 0 if subscription.max_products == -1 else (current_products / subscription.max_products) * 100 if subscription.max_products > 0 else 0 + } + }, + "features": subscription.features or {}, + "next_billing_date": subscription.next_billing_date.isoformat() if subscription.next_billing_date else None, + "trial_ends_at": subscription.trial_ends_at.isoformat() if subscription.trial_ends_at else None + } + + except Exception as e: + logger.error("Failed to get usage summary", + tenant_id=tenant_id, + error=str(e)) + return {"error": "Failed to get usage summary"} + + +# Legacy alias for backward compatibility +SubscriptionService = SubscriptionLimitService \ No newline at end of file diff --git a/services/tenant/app/services/tenant_service.py b/services/tenant/app/services/tenant_service.py index 6e49f5df..7020199f 100644 --- a/services/tenant/app/services/tenant_service.py +++ b/services/tenant/app/services/tenant_service.py @@ -84,10 +84,10 @@ class EnhancedTenantService: owner_membership = await member_repo.create_membership(membership_data) - # Create basic subscription + # Create starter subscription subscription_data = { "tenant_id": str(tenant.id), - "plan": "basic", + "plan": "starter", "status": "active" }