Improve subcription support

This commit is contained in:
Urtzi Alfaro
2025-09-01 19:21:12 +02:00
parent 72b4f60cf5
commit 6346c4bcb9
18 changed files with 3175 additions and 114 deletions

View File

@@ -20,6 +20,7 @@ import {
Bell,
Settings,
User,
CreditCard,
ChevronLeft,
ChevronRight,
ChevronDown,
@@ -95,6 +96,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
notifications: Bell,
settings: Settings,
user: User,
'credit-card': CreditCard,
};
/**

View File

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

View File

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

View File

@@ -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 (
<div className={`w-full bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-full h-3 ${className}`}>
<div
className={`${getProgressColor()} h-full rounded-full transition-all duration-500 relative`}
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-white/20 rounded-full"></div>
</div>
</div>
);
};
// 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<TabsProps> & {
List: React.FC<TabsListProps>;
Trigger: React.FC<TabsTriggerProps>;
Content: React.FC<TabsContentProps>;
} = ({
defaultValue,
className = '',
children
}) => {
const [activeTab, setActiveTab] = useState(defaultValue);
return (
<div className={className}>
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
</div>
);
};
const TabsList: React.FC<TabsListProps> = ({ className = '', children }) => {
return (
<div className={`flex border-b border-[var(--border-primary)] bg-[var(--bg-primary)] ${className}`}>
{children}
</div>
);
};
const TabsTrigger: React.FC<TabsTriggerProps> = ({ 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 (
<button
onClick={() => setActiveTab(value)}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-all duration-200 relative flex items-center ${
isActive
? 'text-[var(--color-primary)] border-[var(--color-primary)] bg-[var(--bg-primary)]'
: 'text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)] hover:border-[var(--color-primary)]/30 hover:bg-[var(--bg-secondary)]'
} ${className}`}
>
{children}
</button>
);
};
const TabsContent: React.FC<TabsContentProps> = ({ 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 (
<div className={`bg-[var(--bg-primary)] rounded-b-lg p-6 ${className}`}>
{children}
</div>
);
};
Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Content = TabsContent;
const PlanComparison: React.FC<PlanComparisonProps> = ({ 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 (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{sortedPlans.map(([planKey, plan]) => (
<Card
key={planKey}
className={`relative p-6 ${getPlanColor(planKey)} ${
currentPlan === planKey ? 'ring-2 ring-[var(--color-primary)]' : ''
}`}
>
{plan.popular && (
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
<Badge variant="primary" className="px-3 py-1">
<Star className="w-3 h-3 mr-1" />
Más Popular
</Badge>
</div>
)}
<div className="text-center mb-6">
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-2">{plan.name}</h3>
<div className="text-3xl font-bold text-[var(--color-primary)] mb-1">
{subscriptionService.formatPrice(plan.monthly_price)}
<span className="text-lg text-[var(--text-secondary)]">/mes</span>
</div>
<p className="text-sm text-[var(--text-secondary)]">{plan.description}</p>
</div>
<div className="space-y-3 mb-6">
<div className="flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-[var(--color-primary)]" />
<span>{plan.max_users === -1 ? 'Usuarios ilimitados' : `${plan.max_users} usuarios`}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-[var(--color-primary)]" />
<span>{plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Package className="w-4 h-4 text-[var(--color-primary)]" />
<span>{plan.max_products === -1 ? 'Productos ilimitados' : `${plan.max_products} productos`}</span>
</div>
</div>
{currentPlan === planKey ? (
<Badge variant="success" className="w-full justify-center py-2">
<CheckCircle className="w-4 h-4 mr-2" />
Plan Actual
</Badge>
) : (
<Button
variant={plan.popular ? 'primary' : 'outline'}
className="w-full"
onClick={() => onUpgrade(planKey)}
>
{plan.contact_sales ? 'Contactar Ventas' : 'Cambiar Plan'}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
</Card>
))}
</div>
);
};
const SubscriptionPage: React.FC = () => {
const { user, tenant_id } = useAuth();
const { currentTenant } = useBakeryStore();
const toast = useToast();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
const [loading, setLoading] = useState(true);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string>('');
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 (
<div className="space-y-6">
<PageHeader
title="Suscripción"
description="Gestiona tu plan de suscripción y facturación"
icon={CreditCard}
loading={loading}
/>
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<p className="text-[var(--text-secondary)]">Cargando información de suscripción...</p>
</div>
</div>
</div>
);
}
if (!usageSummary || !availablePlans) {
return (
<div className="space-y-6">
<PageHeader
title="Suscripción"
description="Gestiona tu plan de suscripción y facturación"
icon={CreditCard}
error="No se pudo cargar la información de suscripción"
onRefresh={loadSubscriptionData}
showRefreshButton
/>
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<AlertCircle className="w-12 h-12 text-[var(--text-tertiary)]" />
<div className="text-center">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se pudo cargar la información</h3>
<p className="text-[var(--text-secondary)] mb-4">Hubo un problema al cargar los datos de suscripción</p>
<Button onClick={loadSubscriptionData} variant="primary">
<RefreshCw className="w-4 h-4 mr-2" />
Reintentar
</Button>
</div>
</div>
</div>
</div>
);
}
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 (
<div className="space-y-6">
<PageHeader
title="Suscripción"
subtitle={`Plan ${planInfo.name}`}
description="Gestiona tu plan de suscripción y facturación"
icon={CreditCard}
status={{
text: usageSummary.status === 'active' ? 'Activo' : usageSummary.status,
variant: usageSummary.status === 'active' ? 'success' : 'default'
}}
actions={[
{
id: 'manage-billing',
label: 'Gestionar Facturación',
icon: ExternalLink,
onClick: () => 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 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="flex items-center gap-4">
<div className="p-3 bg-blue-500/10 rounded-xl border border-blue-500/20">
<Users className="w-6 h-6 text-blue-500" />
</div>
<div className="flex-1">
<p className="text-sm text-[var(--text-secondary)] mb-1">Usuarios</p>
<p className="text-xl font-bold text-[var(--text-primary)]">
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
</p>
</div>
</div>
</Card>
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="flex items-center gap-4">
<div className="p-3 bg-green-500/10 rounded-xl border border-green-500/20">
<MapPin className="w-6 h-6 text-green-500" />
</div>
<div className="flex-1">
<p className="text-sm text-[var(--text-secondary)] mb-1">Ubicaciones</p>
<p className="text-xl font-bold text-[var(--text-primary)]">
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
</p>
</div>
</div>
</Card>
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="flex items-center gap-4">
<div className="p-3 bg-purple-500/10 rounded-xl border border-purple-500/20">
<Package className="w-6 h-6 text-purple-500" />
</div>
<div className="flex-1">
<p className="text-sm text-[var(--text-secondary)] mb-1">Productos</p>
<p className="text-xl font-bold text-[var(--text-primary)]">
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
</p>
</div>
</div>
</Card>
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="flex items-center gap-4">
<div className="p-3 bg-yellow-500/10 rounded-xl border border-yellow-500/20">
<TrendingUp className="w-6 h-6 text-yellow-500" />
</div>
<div className="flex-1">
<p className="text-sm text-[var(--text-secondary)] mb-1">Estado</p>
<Badge
variant={usageSummary.status === 'active' ? 'success' : 'default'}
className="text-sm font-medium"
>
{usageSummary.status === 'active' ? 'Activo' : usageSummary.status}
</Badge>
</div>
</div>
</Card>
</div>
<Card className="overflow-hidden">
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview">
<TrendingUp className="w-4 h-4 mr-2" />
Resumen
</Tabs.Trigger>
<Tabs.Trigger value="usage">
<Users className="w-4 h-4 mr-2" />
Uso
</Tabs.Trigger>
<Tabs.Trigger value="plans">
<Crown className="w-4 h-4 mr-2" />
Planes
</Tabs.Trigger>
<Tabs.Trigger value="billing">
<CreditCard className="w-4 h-4 mr-2" />
Facturación
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Current Plan Summary */}
<Card className="p-6 lg:col-span-2 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<h3 className="text-lg font-semibold mb-6 flex items-center text-[var(--text-primary)]">
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
Tu Plan Actual
</h3>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Plan</span>
<div className="flex items-center gap-2">
<span className="font-semibold text-[var(--text-primary)]">{planInfo.name}</span>
{usageSummary.plan === 'professional' && (
<Badge variant="primary" size="sm">
<Star className="w-3 h-3 mr-1" />
Popular
</Badge>
)}
</div>
</div>
</div>
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Precio</span>
<span className="font-semibold text-[var(--text-primary)]">{subscriptionService.formatPrice(usageSummary.monthly_price)}/mes</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Estado</span>
<Badge variant={usageSummary.status === 'active' ? 'success' : 'error'}>
{usageSummary.status === 'active' ? 'Activo' : 'Inactivo'}
</Badge>
</div>
</div>
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Próxima facturación</span>
<span className="font-medium text-[var(--text-primary)]">{nextBillingDate}</span>
</div>
</div>
</div>
</div>
</Card>
{/* Quick Actions */}
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<h3 className="text-lg font-semibold mb-4 text-[var(--text-primary)]">
Acciones Rápidas
</h3>
<div className="space-y-3">
<Button variant="outline" className="w-full justify-start text-sm" onClick={() => window.open('https://billing.bakery.com', '_blank')}>
<ExternalLink className="w-4 h-4 mr-2" />
Portal de Facturación
</Button>
<Button variant="outline" className="w-full justify-start text-sm" onClick={() => console.log('Download invoice')}>
<Download className="w-4 h-4 mr-2" />
Descargar Facturas
</Button>
<Button variant="outline" className="w-full justify-start text-sm">
<Settings className="w-4 h-4 mr-2" />
Configurar Alertas
</Button>
</div>
</Card>
</div>
{/* Usage at a Glance */}
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<h3 className="text-lg font-semibold mb-6 flex items-center text-[var(--text-primary)]">
<TrendingUp className="w-5 h-5 mr-2 text-orange-500" />
Uso de Recursos
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Users */}
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/10 rounded-lg border border-blue-500/20">
<Users className="w-4 h-4 text-blue-500" />
</div>
<span className="font-medium text-[var(--text-primary)]">Usuarios</span>
</div>
<span className="text-sm font-bold text-[var(--text-primary)]">
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
</span>
</div>
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.users.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.users.unlimited ? 'Ilimitado' : `${usageSummary.usage.users.limit - usageSummary.usage.users.current} restantes`}</span>
</p>
</div>
{/* Locations */}
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500/10 rounded-lg border border-green-500/20">
<MapPin className="w-4 h-4 text-green-500" />
</div>
<span className="font-medium text-[var(--text-primary)]">Ubicaciones</span>
</div>
<span className="text-sm font-bold text-[var(--text-primary)]">
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
</span>
</div>
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.locations.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.locations.unlimited ? 'Ilimitado' : `${usageSummary.usage.locations.limit - usageSummary.usage.locations.current} restantes`}</span>
</p>
</div>
{/* Products */}
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/10 rounded-lg border border-purple-500/20">
<Package className="w-4 h-4 text-purple-500" />
</div>
<span className="font-medium text-[var(--text-primary)]">Productos</span>
</div>
<span className="text-sm font-bold text-[var(--text-primary)]">
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
</span>
</div>
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.products.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.products.unlimited ? 'Ilimitado' : 'Ilimitado'}</span>
</p>
</div>
</div>
</Card>
</Tabs.Content>
<Tabs.Content value="usage">
<div className="space-y-6">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)]">Detalles de Uso</h3>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Users Usage */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-blue-500" />
<h4 className="font-medium text-[var(--text-primary)]">Gestión de Usuarios</h4>
</div>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Usuarios activos</span>
<span className="font-medium">{usageSummary.usage.users.current}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Límite del plan</span>
<span className="font-medium">{usageSummary.usage.users.unlimited ? 'Ilimitado' : usageSummary.usage.users.limit}</span>
</div>
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)]">
{usageSummary.usage.users.usage_percentage}% de capacidad utilizada
</p>
</div>
</div>
{/* Locations Usage */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-green-500" />
<h4 className="font-medium text-[var(--text-primary)]">Ubicaciones</h4>
</div>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Ubicaciones activas</span>
<span className="font-medium">{usageSummary.usage.locations.current}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Límite del plan</span>
<span className="font-medium">{usageSummary.usage.locations.unlimited ? 'Ilimitado' : usageSummary.usage.locations.limit}</span>
</div>
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)]">
{usageSummary.usage.locations.usage_percentage}% de capacidad utilizada
</p>
</div>
</div>
{/* Products Usage */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Package className="w-5 h-5 text-purple-500" />
<h4 className="font-medium text-[var(--text-primary)]">Productos</h4>
</div>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Productos registrados</span>
<span className="font-medium">{usageSummary.usage.products.current}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Límite del plan</span>
<span className="font-medium">{usageSummary.usage.products.unlimited ? 'Ilimitado' : usageSummary.usage.products.limit}</span>
</div>
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)]">
{usageSummary.usage.products.usage_percentage}% de capacidad utilizada
</p>
</div>
</div>
</div>
</Card>
</div>
</Tabs.Content>
<Tabs.Content value="plans">
<div className="space-y-6">
<div className="text-center">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-2">
Planes de Suscripción
</h3>
<p className="text-[var(--text-secondary)]">
Elige el plan que mejor se adapte a las necesidades de tu panadería
</p>
</div>
<PlanComparison
plans={availablePlans.plans}
currentPlan={usageSummary.plan}
onUpgrade={handleUpgradeClick}
/>
</div>
</Tabs.Content>
<Tabs.Content value="billing">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)]">
Información de Facturación
</h3>
<div className="space-y-4">
<div className="flex justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<span className="text-[var(--text-secondary)]">Plan actual:</span>
<span className="font-medium">{planInfo.name}</span>
</div>
<div className="flex justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<span className="text-[var(--text-secondary)]">Precio mensual:</span>
<span className="font-medium">{subscriptionService.formatPrice(usageSummary.monthly_price)}</span>
</div>
<div className="flex justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<span className="text-[var(--text-secondary)]">Próxima facturación:</span>
<span className="font-medium flex items-center">
<Calendar className="w-4 h-4 mr-2" />
{nextBillingDate}
</span>
</div>
</div>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)]">
Métodos de Pago
</h3>
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 border border-[var(--border-primary)] rounded-lg">
<CreditCard className="w-8 h-8 text-[var(--text-tertiary)]" />
<div className="flex-1">
<div className="font-medium"> 4242</div>
<div className="text-sm text-[var(--text-secondary)]">Visa terminada en 4242</div>
</div>
<Badge variant="success">Principal</Badge>
</div>
<Button variant="outline" className="w-full">
<Settings className="w-4 h-4 mr-2" />
Gestionar Métodos de Pago
</Button>
</div>
</Card>
</div>
</Tabs.Content>
</Tabs>
</Card>
{/* Upgrade Modal */}
{upgradeDialogOpen && selectedPlan && availablePlans && (
<Modal
isOpen={upgradeDialogOpen}
onClose={() => setUpgradeDialogOpen(false)}
title="Confirmar Cambio de Plan"
>
<div className="space-y-4">
<p className="text-[var(--text-secondary)]">
¿Estás seguro de que quieres cambiar tu plan de suscripción?
</p>
{availablePlans.plans[selectedPlan] && (
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg space-y-2">
<div className="flex justify-between">
<span>Plan actual:</span>
<span>{planInfo.name}</span>
</div>
<div className="flex justify-between">
<span>Nuevo plan:</span>
<span>{availablePlans.plans[selectedPlan].name}</span>
</div>
<div className="flex justify-between font-medium">
<span>Nuevo precio:</span>
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes</span>
</div>
</div>
)}
<div className="flex gap-2 pt-4">
<Button
variant="outline"
onClick={() => setUpgradeDialogOpen(false)}
className="flex-1"
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleUpgradeConfirm}
disabled={upgrading}
className="flex-1"
>
{upgrading ? 'Procesando...' : 'Confirmar Cambio'}
</Button>
</div>
</div>
</Modal>
)}
</div>
);
};
export default SubscriptionPage;

View File

@@ -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 (
<div className={`w-full bg-gray-200 rounded-full h-2.5 ${className}`}>
<div
className={`${getProgressColor()} h-2.5 rounded-full transition-all duration-300`}
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
);
};
const Alert: React.FC<{ children: React.ReactNode; className?: string }> = ({
children,
className = ''
}) => {
return (
<div className={`p-4 rounded-lg border border-yellow-200 bg-yellow-50 text-yellow-800 ${className}`}>
{children}
</div>
);
};
const Tabs: React.FC<{ defaultValue: string; className?: string; children: React.ReactNode }> = ({
defaultValue,
className = '',
children
}) => {
const [activeTab, setActiveTab] = useState(defaultValue);
return (
<div className={className}>
{React.Children.map(children, child => {
if (React.isValidElement(child)) {
return React.cloneElement(child, { activeTab, setActiveTab } as any);
}
return child;
})}
</div>
);
};
const TabsList: React.FC<{ children: React.ReactNode; activeTab?: string; setActiveTab?: (tab: string) => void }> = ({
children,
activeTab,
setActiveTab
}) => {
return (
<div className="flex space-x-1 border-b border-gray-200 mb-6">
{React.Children.map(children, child => {
if (React.isValidElement(child)) {
return React.cloneElement(child, { activeTab, setActiveTab } as any);
}
return child;
})}
</div>
);
};
const TabsTrigger: React.FC<{
value: string;
children: React.ReactNode;
activeTab?: string;
setActiveTab?: (tab: string) => void;
}> = ({ value, children, activeTab, setActiveTab }) => {
const isActive = activeTab === value;
return (
<button
className={`px-4 py-2 font-medium text-sm border-b-2 transition-colors ${
isActive
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab?.(value)}
>
{children}
</button>
);
};
const TabsContent: React.FC<{
value: string;
children: React.ReactNode;
activeTab?: string;
className?: string;
}> = ({ value, children, activeTab, className = '' }) => {
if (activeTab !== value) return null;
return <div className={className}>{children}</div>;
};
const Separator: React.FC<{ className?: string }> = ({ className = '' }) => {
return <hr className={`border-gray-200 my-4 ${className}`} />;
};
const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onUpgrade }) => {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{Object.entries(plans).map(([key, plan]) => {
const isCurrentPlan = key === currentPlan;
const isPopular = plan.popular;
return (
<div
key={key}
className={`relative rounded-2xl p-6 border-2 transition-all duration-200 ${
isCurrentPlan
? 'border-blue-500 bg-blue-50'
: isPopular
? 'border-purple-200 bg-gradient-to-b from-purple-50 to-white shadow-lg scale-105'
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-md'
}`}
>
{isPopular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<Badge className="bg-gradient-to-r from-purple-600 to-purple-700 text-white px-4 py-1">
<Star className="w-3 h-3 mr-1" />
Más Popular
</Badge>
</div>
)}
{isCurrentPlan && (
<div className="absolute top-4 right-4">
<Badge variant="default" className="bg-green-100 text-green-800">
<CheckCircle className="w-3 h-3 mr-1" />
Activo
</Badge>
</div>
)}
<div className="text-center mb-6">
<h3 className="text-2xl font-bold text-gray-900 mb-2">{plan.name}</h3>
<p className="text-gray-600 text-sm mb-4">{plan.description}</p>
<div className="mb-4">
<span className="text-4xl font-bold text-gray-900">
{plan.monthly_price}
</span>
<span className="text-gray-600">/mes</span>
</div>
{plan.trial_available && !isCurrentPlan && (
<p className="text-blue-600 text-sm font-medium">
14 días de prueba gratis
</p>
)}
</div>
<div className="space-y-3 mb-8">
<div className="flex items-center text-sm">
<Users className="w-4 h-4 text-gray-500 mr-3" />
<span>
{plan.max_users === -1 ? 'Usuarios ilimitados' : `Hasta ${plan.max_users} usuarios`}
</span>
</div>
<div className="flex items-center text-sm">
<MapPin className="w-4 h-4 text-gray-500 mr-3" />
<span>
{plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`}
</span>
</div>
<div className="flex items-center text-sm">
<Package className="w-4 h-4 text-gray-500 mr-3" />
<span>
{plan.max_products === -1 ? 'Productos ilimitados' : `Hasta ${plan.max_products} productos`}
</span>
</div>
{/* Key features */}
<div className="pt-2">
<div className="flex items-center text-sm mb-2">
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
<span>
{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'}
</span>
</div>
<div className="flex items-center text-sm mb-2">
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
<span>
{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'}
</span>
</div>
{plan.features.pos_integrated && (
<div className="flex items-center text-sm mb-2">
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
<span>POS integrado</span>
</div>
)}
{plan.features.erp_integration && (
<div className="flex items-center text-sm mb-2">
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
<span>Integración ERP</span>
</div>
)}
{plan.features.account_manager && (
<div className="flex items-center text-sm mb-2">
<Crown className="w-4 h-4 text-yellow-500 mr-3" />
<span>Manager dedicado</span>
</div>
)}
</div>
</div>
<Button
className={`w-full ${
isCurrentPlan
? 'bg-gray-300 text-gray-600 cursor-not-allowed'
: isPopular
? 'bg-gradient-to-r from-purple-600 to-purple-700 hover:from-purple-700 hover:to-purple-800'
: ''
}`}
onClick={() => !isCurrentPlan && onUpgrade(key)}
disabled={isCurrentPlan}
>
{isCurrentPlan ? (
'Plan Actual'
) : plan.contact_sales ? (
'Contactar Ventas'
) : (
<>
Cambiar a {plan.name}
<ArrowRight className="w-4 h-4 ml-2" />
</>
)}
</Button>
</div>
);
})}
</div>
);
};
const SubscriptionPage: React.FC = () => {
const { user, tenant_id } = useAuth();
const { currentTenant } = useBakeryStore();
const toast = useToast();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
const [loading, setLoading] = useState(true);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string>('');
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 (
<div className="space-y-6">
<PageHeader
title="Suscripción"
description="Gestiona tu plan de suscripción y facturación"
icon={CreditCard}
loading={loading}
/>
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<p className="text-[var(--text-secondary)]">Cargando información de suscripción...</p>
</div>
</div>
</div>
);
}
if (!usageSummary || !availablePlans) {
return (
<div className="space-y-6">
<PageHeader
title="Suscripción"
description="Gestiona tu plan de suscripción y facturación"
icon={CreditCard}
error="No se pudo cargar la información de suscripción"
onRefresh={loadSubscriptionData}
showRefreshButton
/>
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<AlertCircle className="w-12 h-12 text-[var(--text-tertiary)]" />
<div className="text-center">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se pudo cargar la información</h3>
<p className="text-[var(--text-secondary)] mb-4">Hubo un problema al cargar los datos de suscripción</p>
<Button onClick={loadSubscriptionData} variant="primary">
<RefreshCw className="w-4 h-4 mr-2" />
Reintentar
</Button>
</div>
</div>
</div>
</div>
);
}
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 (
<div className="p-6 max-w-7xl mx-auto space-y-8">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900">Suscripción</h1>
<p className="text-gray-600 mt-2">
Gestiona tu plan, facturación y límites de uso
</p>
</div>
</div>
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview">Resumen</TabsTrigger>
<TabsTrigger value="usage">Uso Actual</TabsTrigger>
<TabsTrigger value="plans">Cambiar Plan</TabsTrigger>
<TabsTrigger value="billing">Facturación</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
{/* Current Plan Overview */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold flex items-center">
Plan {subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}
{usageSummary.plan === 'professional' && (
<Badge className="ml-2 bg-purple-100 text-purple-800">
<Star className="w-3 h-3 mr-1" />
Más Popular
</Badge>
)}
</h2>
<p className="text-gray-600 mt-1">
{subscriptionService.formatPrice(usageSummary.monthly_price)}/mes
Estado: {usageSummary.status === 'active' ? 'Activo' : usageSummary.status}
</p>
</div>
<div className="text-right">
<div className="text-sm text-gray-500">Próxima facturación</div>
<div className="font-medium">{nextBillingDate}</div>
</div>
</div>
</CardHeader>
<CardContent>
{trialEndsAt && (
<Alert className="mb-4">
<Zap className="h-4 w-4 inline mr-2" />
Tu período de prueba gratuita termina el {trialEndsAt}
</Alert>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<Users className="w-8 h-8 text-blue-600 mx-auto mb-2" />
<div className="text-2xl font-bold">
{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}
</div>
<div className="text-sm text-gray-600">Usuarios</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<MapPin className="w-8 h-8 text-blue-600 mx-auto mb-2" />
<div className="text-2xl font-bold">
{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}
</div>
<div className="text-sm text-gray-600">Ubicaciones</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<Package className="w-8 h-8 text-blue-600 mx-auto mb-2" />
<div className="text-2xl font-bold">
{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}
</div>
<div className="text-sm text-gray-600">Productos</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="usage" className="space-y-6">
{/* Usage Details */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold flex items-center">
<Users className="w-5 h-5 mr-2" />
Usuarios
</h3>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Usado</span>
<span className={`text-sm font-medium ${getUsageColor(usageSummary.usage.users.usage_percentage)}`}>
{usageSummary.usage.users.current} de {usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}
</span>
</div>
{!usageSummary.usage.users.unlimited && (
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold flex items-center">
<MapPin className="w-5 h-5 mr-2" />
Ubicaciones
</h3>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Usado</span>
<span className={`text-sm font-medium ${getUsageColor(usageSummary.usage.locations.usage_percentage)}`}>
{usageSummary.usage.locations.current} de {usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}
</span>
</div>
{!usageSummary.usage.locations.unlimited && (
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold flex items-center">
<Package className="w-5 h-5 mr-2" />
Productos
</h3>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Usado</span>
<span className={`text-sm font-medium ${getUsageColor(usageSummary.usage.products.usage_percentage)}`}>
{usageSummary.usage.products.current} de {usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}
</span>
</div>
{!usageSummary.usage.products.unlimited && (
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
)}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="plans" className="space-y-6">
<div>
<h2 className="text-2xl font-semibold mb-6">Planes Disponibles</h2>
<PlanComparison
plans={availablePlans.plans}
currentPlan={usageSummary.plan}
onUpgrade={handleUpgradeClick}
/>
</div>
</TabsContent>
<TabsContent value="billing" className="space-y-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold flex items-center">
<CreditCard className="w-5 h-5 mr-2" />
Información de Facturación
</h3>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-600">Plan Actual</div>
<div className="font-medium">
{subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}
</div>
</div>
<div>
<div className="text-sm text-gray-600">Precio Mensual</div>
<div className="font-medium">
{subscriptionService.formatPrice(usageSummary.monthly_price)}
</div>
</div>
<div>
<div className="text-sm text-gray-600">Próxima Facturación</div>
<div className="font-medium flex items-center">
<Calendar className="w-4 h-4 mr-2 text-gray-500" />
{nextBillingDate}
</div>
</div>
<div>
<div className="text-sm text-gray-600">Estado</div>
<Badge
variant="default"
className={usageSummary.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}
>
{usageSummary.status === 'active' ? 'Activo' : usageSummary.status}
</Badge>
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="text-sm font-medium">Próximos Cobros</div>
<div className="text-sm text-gray-600">
Se facturará {subscriptionService.formatPrice(usageSummary.monthly_price)} el {nextBillingDate}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Upgrade Confirmation Modal */}
{upgradeDialogOpen && (
<Modal onClose={() => setUpgradeDialogOpen(false)}>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Confirmar Cambio de Plan</h3>
<button
onClick={() => setUpgradeDialogOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-600 mb-4">
¿Estás seguro de que quieres cambiar al plan{' '}
{selectedPlan && availablePlans?.plans[selectedPlan]?.name}?
</p>
{selectedPlan && availablePlans?.plans[selectedPlan] && (
<div className="py-4 border-t border-b border-gray-200">
<div className="space-y-2">
<div className="flex justify-between">
<span>Plan actual:</span>
<span>{subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}</span>
</div>
<div className="flex justify-between">
<span>Nuevo plan:</span>
<span>{availablePlans.plans[selectedPlan].name}</span>
</div>
<div className="flex justify-between font-medium">
<span>Nuevo precio:</span>
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes</span>
</div>
</div>
</div>
)}
<div className="flex justify-end space-x-3 mt-6">
<Button
variant="outline"
onClick={() => setUpgradeDialogOpen(false)}
disabled={upgrading}
>
Cancelar
</Button>
<Button
onClick={handleUpgradeConfirm}
disabled={upgrading}
>
{upgrading ? 'Procesando...' : 'Confirmar Cambio'}
</Button>
</div>
</div>
</Modal>
)}
</div>
);
};
export default SubscriptionPage;

View File

@@ -0,0 +1 @@
export { default } from './SubscriptionPage';

View File

@@ -4,4 +4,5 @@ export * from './public';
// App pages
export { default as DashboardPage } from './app/DashboardPage';
export * from './app/operations';
export * from './app/analytics';
export * from './app/analytics';
export * from './app/settings';

View File

@@ -459,116 +459,312 @@ const LandingPage: React.FC = () => {
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Starter Plan */}
<div className="bg-[var(--bg-secondary)] rounded-2xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Starter</h3>
<p className="mt-2 text-sm text-[var(--text-secondary)]">Perfecto para panaderías pequeñas</p>
<div className="mt-6">
<span className="text-3xl font-bold text-[var(--text-primary)]">49</span>
<span className="text-[var(--text-secondary)]">/mes</span>
</div>
<div className="mt-8 space-y-4">
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Hasta 50 productos</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Predicción básica de demanda</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Control de inventario</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Reportes básicos</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Soporte por email</span>
<div className="group relative bg-[var(--bg-secondary)] rounded-3xl p-8 border-2 border-[var(--border-primary)] hover:border-[var(--color-primary)]/30 transition-all duration-300 hover:shadow-xl hover:-translate-y-1">
<div className="absolute top-6 right-6">
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<Package className="w-6 h-6 text-[var(--color-primary)]" />
</div>
</div>
<Button className="w-full mt-8" variant="outline">
<div className="mb-6">
<h3 className="text-2xl font-bold text-[var(--text-primary)]">Starter</h3>
<p className="mt-3 text-[var(--text-secondary)] leading-relaxed">Ideal para panaderías pequeñas o nuevas</p>
</div>
<div className="mb-8">
<div className="flex items-baseline">
<span className="text-5xl font-bold text-[var(--text-primary)]">49</span>
<span className="ml-2 text-lg text-[var(--text-secondary)]">/mes</span>
</div>
<div className="mt-2 px-3 py-1 bg-[var(--color-success)]/10 text-[var(--color-success)] text-sm font-medium rounded-full inline-block">
14 días gratis
</div>
</div>
<div className="space-y-4 mb-8">
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Hasta 50 productos</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Control de inventario básico</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Predicción básica de demanda</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Reportes básicos de producción</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Analytics básicos</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">1 ubicación</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Soporte por email</span>
</div>
</div>
<Button className="w-full py-4 text-base font-semibold border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white transition-all duration-200 group-hover:shadow-lg" variant="outline">
Comenzar Gratis
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
{/* Professional Plan - Highlighted */}
<div className="bg-gradient-to-b from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-2xl p-8 relative shadow-2xl">
<div className="group relative bg-gradient-to-br from-[var(--color-primary)] via-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-3xl p-8 shadow-2xl transform scale-105 z-10">
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<span className="bg-[var(--color-secondary)] text-white px-4 py-1 rounded-full text-sm font-semibold">
Más Popular
</span>
</div>
<h3 className="text-lg font-semibold text-white">Professional</h3>
<p className="mt-2 text-sm text-white/80">Para panaderías en crecimiento</p>
<div className="mt-6">
<span className="text-3xl font-bold text-white">149</span>
<span className="text-white/80">/mes</span>
</div>
<div className="mt-8 space-y-4">
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">Productos ilimitados</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">IA avanzada con 92% precisión</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">Gestión completa de producción</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">POS integrado</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">Analytics avanzado</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">Soporte prioritario 24/7</span>
<div className="bg-gradient-to-r from-[var(--color-secondary)] to-[var(--color-secondary-dark)] text-white px-6 py-2 rounded-full text-sm font-bold shadow-lg">
Más Popular
</div>
</div>
<Button className="w-full mt-8 bg-white text-[var(--color-primary)] hover:bg-[var(--bg-tertiary)]">
<div className="absolute top-6 right-6">
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-white" />
</div>
</div>
<div className="mb-6 pt-4">
<h3 className="text-2xl font-bold text-white">Professional</h3>
<p className="mt-3 text-white/90 leading-relaxed">Ideal para panaderías y cadenas en crecimiento</p>
</div>
<div className="mb-8">
<div className="flex items-baseline">
<span className="text-5xl font-bold text-white">129</span>
<span className="ml-2 text-lg text-white/80">/mes</span>
</div>
<div className="mt-2 px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full inline-block">
14 días gratis
</div>
</div>
<div className="space-y-4 mb-8">
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Productos ilimitados</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Control de inventario avanzado</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">IA Avanzada con 92% de precisión</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Gestión completa de producción</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">POS integrado</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Gestión de Logística Básica</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Analytics avanzados</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">1-2 ubicaciones</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Soporte prioritario 24/7</span>
</div>
</div>
<Button className="w-full py-4 text-base font-semibold bg-white text-[var(--color-primary)] hover:bg-gray-100 transition-all duration-200 shadow-lg hover:shadow-xl">
Comenzar Prueba Gratuita
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
{/* Enterprise Plan */}
<div className="bg-[var(--bg-secondary)] rounded-2xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Enterprise</h3>
<p className="mt-2 text-sm text-[var(--text-secondary)]">Para cadenas y grandes operaciones</p>
<div className="mt-6">
<span className="text-3xl font-bold text-[var(--text-primary)]">399</span>
<span className="text-[var(--text-secondary)]">/mes</span>
</div>
<div className="mt-8 space-y-4">
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Multi-locación ilimitada</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">IA personalizada por ubicación</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">API personalizada</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Integración ERPs</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Manager dedicado</span>
<div className="group relative bg-[var(--bg-secondary)] rounded-3xl p-8 border-2 border-[var(--border-primary)] hover:border-[var(--color-accent)]/30 transition-all duration-300 hover:shadow-xl hover:-translate-y-1">
<div className="absolute top-6 right-6">
<div className="w-12 h-12 bg-[var(--color-accent)]/10 rounded-full flex items-center justify-center">
<Settings className="w-6 h-6 text-[var(--color-accent)]" />
</div>
</div>
<Button className="w-full mt-8" variant="outline">
<div className="mb-6">
<h3 className="text-2xl font-bold text-[var(--text-primary)]">Enterprise</h3>
<p className="mt-3 text-[var(--text-secondary)] leading-relaxed">Ideal para cadenas con obradores centrales</p>
</div>
<div className="mb-8">
<div className="flex items-baseline">
<span className="text-5xl font-bold text-[var(--text-primary)]">399</span>
<span className="ml-2 text-lg text-[var(--text-secondary)]">/mes</span>
</div>
<div className="mt-2 px-3 py-1 bg-[var(--color-accent)]/10 text-[var(--color-accent)] text-sm font-medium rounded-full inline-block">
Demo personalizada
</div>
</div>
<div className="space-y-4 mb-8 max-h-80 overflow-y-auto pr-2 scrollbar-thin">
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Productos ilimitados</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Control de inventario multi-locación</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">IA personalizada por ubicación</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Optimización de capacidad</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Integración con ERPs</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Gestión de Logística Avanzada</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Analytics predictivos</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Ubicaciones y obradores ilimitados</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">API Personalizada</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Manager de Cuenta Dedicado</span>
</div>
</div>
<Button className="w-full py-4 text-base font-semibold border-2 border-[var(--color-accent)] text-[var(--color-accent)] hover:bg-[var(--color-accent)] hover:text-white transition-all duration-200 group-hover:shadow-lg" variant="outline">
Contactar Ventas
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
</div>

View File

@@ -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 = () => {
</ProtectedRoute>
}
/>
<Route
path="/app/settings/subscription"
element={
<ProtectedRoute>
<AppShell>
<SubscriptionPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Data Routes */}
<Route

View File

@@ -123,6 +123,7 @@ export const ROUTES = {
SETTINGS_INTEGRATIONS: '/settings/integrations',
SETTINGS_PREFERENCES: '/settings/preferences',
SETTINGS_BILLING: '/settings/billing',
SETTINGS_SUBSCRIPTION: '/app/settings/subscription',
// Reports
REPORTS: '/reports',
@@ -399,6 +400,17 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/settings/subscription',
name: 'Subscription',
component: 'SubscriptionPage',
title: 'Suscripción y Facturación',
icon: 'credit-card',
requiresAuth: true,
requiredRoles: ['admin', 'owner'],
showInNavigation: true,
showInBreadcrumbs: true,
},
],
},

View File

@@ -218,4 +218,5 @@ class ApiClient {
}
}
export { ApiClient };
export const apiClient = new ApiClient();

View File

@@ -1,5 +1,6 @@
// Export API client and types
export * from './client';
export { ApiClient } from './client';
// Export all services
export * from './auth.service';
@@ -14,6 +15,7 @@ export * from './pos.service';
export * from './data.service';
export * from './training.service';
export * from './notification.service';
export * from './subscription.service';
// Service instances for easy importing
export { authService } from './auth.service';
@@ -28,6 +30,7 @@ export { posService } from './pos.service';
export { dataService } from './data.service';
export { trainingService } from './training.service';
export { notificationService } from './notification.service';
export { subscriptionService } from './subscription.service';
// API client instance
export { apiClient } from './client';

View File

@@ -0,0 +1,481 @@
/**
* Subscription Service
* Handles API calls for subscription management, billing, and plan limits
*/
import { ApiClient } from './client';
import { isMockMode, getMockSubscription } from '../../config/mock.config';
export interface SubscriptionLimits {
plan: string;
max_users: number;
max_locations: number;
max_products: number;
features: Record<string, any>;
}
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<string, any>;
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<string, any>;
reason: string;
}
export interface AvailablePlan {
name: string;
description: string;
monthly_price: number;
max_users: number;
max_locations: number;
max_products: number;
features: Record<string, any>;
trial_available: boolean;
popular?: boolean;
contact_sales?: boolean;
}
export interface AvailablePlans {
plans: Record<string, AvailablePlan>;
}
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<string, any>;
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<SubscriptionLimits> {
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<UsageSummary> {
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<LimitCheckResult> {
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<LimitCheckResult> {
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<LimitCheckResult> {
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<FeatureCheckResult> {
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<PlanUpgradeValidation> {
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<SubscriptionData> {
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<BillingHistoryItem[]> {
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<AvailablePlans> {
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<boolean> {
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<string | null> {
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;

View File

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

View File

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

View File

@@ -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"])
return plan_configs.get(plan, plan_configs["starter"])

View File

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

View File

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