Improve subcription support
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
Settings,
|
Settings,
|
||||||
User,
|
User,
|
||||||
|
CreditCard,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -95,6 +96,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
|||||||
notifications: Bell,
|
notifications: Bell,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
user: User,
|
user: User,
|
||||||
|
'credit-card': CreditCard,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ export const MOCK_CONFIG = {
|
|||||||
tenant_name: 'Panadería Artesanal El Buen Pan'
|
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 data
|
||||||
MOCK_BAKERY: {
|
MOCK_BAKERY: {
|
||||||
name: 'Panadería Artesanal El Buen Pan',
|
name: 'Panadería Artesanal El Buen Pan',
|
||||||
@@ -27,6 +35,69 @@ export const MOCK_CONFIG = {
|
|||||||
location: 'Av. Principal 123, Centro Histórico',
|
location: 'Av. Principal 123, Centro Histórico',
|
||||||
phone: '+1 234 567 8900',
|
phone: '+1 234 567 8900',
|
||||||
email: 'info@elbuenpan.com'
|
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'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,3 +106,7 @@ export const isMockMode = () => MOCK_CONFIG.MOCK_MODE;
|
|||||||
export const isMockRegistration = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_REGISTRATION;
|
export const isMockRegistration = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_REGISTRATION;
|
||||||
export const isMockAuthentication = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_AUTHENTICATION;
|
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;
|
||||||
7
frontend/src/pages/app/settings/index.ts
Normal file
7
frontend/src/pages/app/settings/index.ts
Normal 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';
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
1
frontend/src/pages/app/settings/subscription/index.ts
Normal file
1
frontend/src/pages/app/settings/subscription/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './SubscriptionPage';
|
||||||
@@ -5,3 +5,4 @@ export * from './public';
|
|||||||
export { default as DashboardPage } from './app/DashboardPage';
|
export { default as DashboardPage } from './app/DashboardPage';
|
||||||
export * from './app/operations';
|
export * from './app/operations';
|
||||||
export * from './app/analytics';
|
export * from './app/analytics';
|
||||||
|
export * from './app/settings';
|
||||||
@@ -459,116 +459,312 @@ const LandingPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
{/* Starter Plan */}
|
{/* Starter Plan */}
|
||||||
<div className="bg-[var(--bg-secondary)] rounded-2xl p-8 border border-[var(--border-primary)]">
|
<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">
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Starter</h3>
|
<div className="absolute top-6 right-6">
|
||||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">Perfecto para panaderías pequeñas</p>
|
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||||
<div className="mt-6">
|
<Package className="w-6 h-6 text-[var(--color-primary)]" />
|
||||||
<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>
|
</div>
|
||||||
</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
|
Comenzar Gratis
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Professional Plan - Highlighted */}
|
{/* 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">
|
<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">
|
<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
|
⭐ 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>
|
</div>
|
||||||
</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
|
Comenzar Prueba Gratuita
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enterprise Plan */}
|
{/* Enterprise Plan */}
|
||||||
<div className="bg-[var(--bg-secondary)] rounded-2xl p-8 border border-[var(--border-primary)]">
|
<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">
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Enterprise</h3>
|
<div className="absolute top-6 right-6">
|
||||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">Para cadenas y grandes operaciones</p>
|
<div className="w-12 h-12 bg-[var(--color-accent)]/10 rounded-full flex items-center justify-center">
|
||||||
<div className="mt-6">
|
<Settings className="w-6 h-6 text-[var(--color-accent)]" />
|
||||||
<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>
|
</div>
|
||||||
</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
|
Contactar Ventas
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 SystemSettingsPage = React.lazy(() => import('../pages/app/settings/system/SystemSettingsPage'));
|
||||||
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
|
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
|
||||||
const TrainingPage = React.lazy(() => import('../pages/app/settings/training/TrainingPage'));
|
const TrainingPage = React.lazy(() => import('../pages/app/settings/training/TrainingPage'));
|
||||||
|
const SubscriptionPage = React.lazy(() => import('../pages/app/settings/subscription/SubscriptionPage'));
|
||||||
|
|
||||||
// Data pages
|
// Data pages
|
||||||
const WeatherPage = React.lazy(() => import('../pages/app/data/weather/WeatherPage'));
|
const WeatherPage = React.lazy(() => import('../pages/app/data/weather/WeatherPage'));
|
||||||
@@ -265,6 +266,16 @@ export const AppRouter: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/app/settings/subscription"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<SubscriptionPage />
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Data Routes */}
|
{/* Data Routes */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export const ROUTES = {
|
|||||||
SETTINGS_INTEGRATIONS: '/settings/integrations',
|
SETTINGS_INTEGRATIONS: '/settings/integrations',
|
||||||
SETTINGS_PREFERENCES: '/settings/preferences',
|
SETTINGS_PREFERENCES: '/settings/preferences',
|
||||||
SETTINGS_BILLING: '/settings/billing',
|
SETTINGS_BILLING: '/settings/billing',
|
||||||
|
SETTINGS_SUBSCRIPTION: '/app/settings/subscription',
|
||||||
|
|
||||||
// Reports
|
// Reports
|
||||||
REPORTS: '/reports',
|
REPORTS: '/reports',
|
||||||
@@ -399,6 +400,17 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
showInNavigation: true,
|
showInNavigation: true,
|
||||||
showInBreadcrumbs: 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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -218,4 +218,5 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ApiClient };
|
||||||
export const apiClient = new ApiClient();
|
export const apiClient = new ApiClient();
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Export API client and types
|
// Export API client and types
|
||||||
export * from './client';
|
export * from './client';
|
||||||
|
export { ApiClient } from './client';
|
||||||
|
|
||||||
// Export all services
|
// Export all services
|
||||||
export * from './auth.service';
|
export * from './auth.service';
|
||||||
@@ -14,6 +15,7 @@ export * from './pos.service';
|
|||||||
export * from './data.service';
|
export * from './data.service';
|
||||||
export * from './training.service';
|
export * from './training.service';
|
||||||
export * from './notification.service';
|
export * from './notification.service';
|
||||||
|
export * from './subscription.service';
|
||||||
|
|
||||||
// Service instances for easy importing
|
// Service instances for easy importing
|
||||||
export { authService } from './auth.service';
|
export { authService } from './auth.service';
|
||||||
@@ -28,6 +30,7 @@ export { posService } from './pos.service';
|
|||||||
export { dataService } from './data.service';
|
export { dataService } from './data.service';
|
||||||
export { trainingService } from './training.service';
|
export { trainingService } from './training.service';
|
||||||
export { notificationService } from './notification.service';
|
export { notificationService } from './notification.service';
|
||||||
|
export { subscriptionService } from './subscription.service';
|
||||||
|
|
||||||
// API client instance
|
// API client instance
|
||||||
export { apiClient } from './client';
|
export { apiClient } from './client';
|
||||||
481
frontend/src/services/api/subscription.service.ts
Normal file
481
frontend/src/services/api/subscription.service.ts
Normal 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;
|
||||||
330
services/tenant/app/api/subscriptions.py
Normal file
330
services/tenant/app/api/subscriptions.py
Normal 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"
|
||||||
|
)
|
||||||
@@ -4,7 +4,7 @@ Tenant models for bakery management - FIXED
|
|||||||
Removed cross-service User relationship to eliminate circular dependencies
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -35,7 +35,7 @@ class Tenant(Base):
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
subscription_tier = Column(String(50), default="basic")
|
subscription_tier = Column(String(50), default="starter")
|
||||||
|
|
||||||
# ML status
|
# ML status
|
||||||
model_trained = Column(Boolean, default=False)
|
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)
|
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)
|
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
|
status = Column(String(50), default="active") # active, suspended, cancelled
|
||||||
|
|
||||||
# Billing
|
# Billing
|
||||||
@@ -102,10 +102,13 @@ class Subscription(Base):
|
|||||||
trial_ends_at = Column(DateTime(timezone=True))
|
trial_ends_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
# Limits
|
# Limits
|
||||||
max_users = Column(Integer, default=1)
|
max_users = Column(Integer, default=5)
|
||||||
max_locations = Column(Integer, default=1)
|
max_locations = Column(Integer, default=1)
|
||||||
max_products = Column(Integer, default=50)
|
max_products = Column(Integer, default=50)
|
||||||
|
|
||||||
|
# Features - Store plan features as JSON
|
||||||
|
features = Column(JSON)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
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))
|
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ class SubscriptionRepository(TenantBaseRepository):
|
|||||||
) -> Optional[Subscription]:
|
) -> Optional[Subscription]:
|
||||||
"""Update subscription plan and pricing"""
|
"""Update subscription plan and pricing"""
|
||||||
try:
|
try:
|
||||||
valid_plans = ["basic", "professional", "enterprise"]
|
valid_plans = ["starter", "professional", "enterprise"]
|
||||||
if new_plan not in valid_plans:
|
if new_plan not in valid_plans:
|
||||||
raise ValidationError(f"Invalid plan. Must be one of: {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_users": plan_config["max_users"],
|
||||||
"max_locations": plan_config["max_locations"],
|
"max_locations": plan_config["max_locations"],
|
||||||
"max_products": plan_config["max_products"],
|
"max_products": plan_config["max_products"],
|
||||||
|
"features": plan_config.get("features", {}),
|
||||||
"updated_at": datetime.utcnow()
|
"updated_at": datetime.utcnow()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,24 +398,56 @@ class SubscriptionRepository(TenantBaseRepository):
|
|||||||
def _get_plan_configuration(self, plan: str) -> Dict[str, Any]:
|
def _get_plan_configuration(self, plan: str) -> Dict[str, Any]:
|
||||||
"""Get configuration for a subscription plan"""
|
"""Get configuration for a subscription plan"""
|
||||||
plan_configs = {
|
plan_configs = {
|
||||||
"basic": {
|
"starter": {
|
||||||
"monthly_price": 29.99,
|
"monthly_price": 49.0,
|
||||||
"max_users": 2,
|
"max_users": 5, # Reasonable for small bakeries
|
||||||
"max_locations": 1,
|
"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": {
|
"professional": {
|
||||||
"monthly_price": 79.99,
|
"monthly_price": 129.0,
|
||||||
"max_users": 10,
|
"max_users": 15, # Good for growing bakeries
|
||||||
"max_locations": 3,
|
"max_locations": 2,
|
||||||
"max_products": 200
|
"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": {
|
"enterprise": {
|
||||||
"monthly_price": 199.99,
|
"monthly_price": 399.0,
|
||||||
"max_users": 50,
|
"max_users": -1, # Unlimited users
|
||||||
"max_locations": 10,
|
"max_locations": -1, # Unlimited locations
|
||||||
"max_products": 1000
|
"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"])
|
||||||
332
services/tenant/app/services/subscription_limit_service.py
Normal file
332
services/tenant/app/services/subscription_limit_service.py
Normal 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
|
||||||
@@ -84,10 +84,10 @@ class EnhancedTenantService:
|
|||||||
|
|
||||||
owner_membership = await member_repo.create_membership(membership_data)
|
owner_membership = await member_repo.create_membership(membership_data)
|
||||||
|
|
||||||
# Create basic subscription
|
# Create starter subscription
|
||||||
subscription_data = {
|
subscription_data = {
|
||||||
"tenant_id": str(tenant.id),
|
"tenant_id": str(tenant.id),
|
||||||
"plan": "basic",
|
"plan": "starter",
|
||||||
"status": "active"
|
"status": "active"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user