'use client'; import { useMemo, useState } from 'react'; import { Header } from '@/components/layouts/header'; import { Card, CardContent, CardHeader, CardTitle, Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui'; import { useAuthStore } from '@/stores/auth-store'; import { isGlobalAdminRfc } from '@horux/shared'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api/client'; import { useSubscription, usePaymentHistory, useGeneratePaymentLink, usePlans, useStartTrial, useSubscribeMe, useChangeMyPlan, useCancelMySubscription, useUpgradeMe, useCancelPendingUpgrade, useReactivateMe, } from '@/lib/hooks/use-subscription'; import { CreditCard, Calendar, CheckCircle, AlertCircle, Clock, XCircle, ExternalLink, Loader2, AlertTriangle, CalendarClock, Building, Sparkles, ArrowRight, Gift, } from 'lucide-react'; // ============================================================================ // Helpers // ============================================================================ const PLAN_ORDER = ['starter', 'business', 'business_ia', 'enterprise'] as const; const PLAN_LABELS: Record = { starter: 'Starter', business: 'Business', business_ia: 'Business + IA', custom: 'Custom', enterprise: 'Enterprise', }; const PLAN_FEATURES: Record = { starter: ['Dashboard básico', 'CFDI manual', 'Cálculo IVA/ISR'], business: ['Todo Starter', '50 CFDIs / mes', '3 usuarios', 'Reportes avanzados', 'Alertas fiscales', 'Conciliación bancaria', 'Sincronización SAT', 'Documentos (Opinión Cumplimiento)'], business_ia: ['Todo Business', 'Lolita — agente IA fiscal'], enterprise: ['Todo Business', 'Lolita — agente IA fiscal', '100 CFDIs / mes', 'Usuarios ilimitados', 'API de integración'], }; const statusConfig: Record = { authorized: { label: 'Activa', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle }, trial: { label: 'Prueba gratis', color: 'text-blue-700', bgColor: 'bg-blue-50 border-blue-200', icon: Sparkles }, trial_expired: { label: 'Prueba vencida', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle }, trial_converted: { label: 'Prueba convertida', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle }, pending: { label: 'Pendiente de pago', color: 'text-yellow-700', bgColor: 'bg-yellow-50 border-yellow-200', icon: Clock }, paused: { label: 'Pausada', color: 'text-orange-700', bgColor: 'bg-orange-50 border-orange-200', icon: AlertCircle }, cancelled: { label: 'Cancelada', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle }, }; function getDaysUntil(dateStr: string | null | undefined): number | null { if (!dateStr) return null; const diff = new Date(dateStr).getTime() - Date.now(); return Math.ceil(diff / (1000 * 60 * 60 * 24)); } function formatDate(dateStr: string | null | undefined): string { if (!dateStr) return '—'; return new Date(dateStr).toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' }); } function formatAmount(amount: number | string): string { return `$${Number(amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} MXN`; } // ============================================================================ // Plan Grid — componente reusable para picker y modal // ============================================================================ interface PlanGridProps { frequency: 'monthly' | 'annual'; selectedPlan: string | null; currentPlan?: string | null; // resalta el plan actual cuando está en modal onSelect: (plan: string) => void; prices: Array<{ plan: string; frequency: string; amount: string }>; } function PlanGrid({ frequency, selectedPlan, currentPlan, onSelect, prices }: PlanGridProps) { const priceByPlan = useMemo(() => { const m = new Map(); for (const p of prices) { if (p.frequency === frequency) m.set(p.plan, Number(p.amount)); } return m; }, [prices, frequency]); return (
{PLAN_ORDER.map((plan) => { const price = priceByPlan.get(plan) ?? 0; const isSelected = selectedPlan === plan; const isCurrent = currentPlan === plan; const features = PLAN_FEATURES[plan] || []; return ( ); })}
); } // ============================================================================ // Admin global: vista de todas las suscripciones // ============================================================================ // Edición de precios de planes movida a /configuracion/precios-suscripcion. function AdminGlobalSubscriptions() { const { data: subscriptions, isLoading } = useQuery({ queryKey: ['all-subscriptions'], queryFn: () => apiClient.get('/subscriptions').then(r => r.data), }); if (isLoading) return
Cargando...
; const subs = (subscriptions || []) as any[]; const activas = subs.filter((s: any) => s.status === 'authorized' || s.status === 'active'); const pendientes = subs.filter((s: any) => s.status === 'pending'); const canceladas = subs.filter((s: any) => s.status === 'cancelled' || s.status === 'paused'); return (

{subs.length}

Total

{activas.length}

Activas

{pendientes.length}

Pendientes

{canceladas.length}

Canceladas

Todas las Suscripciones {subs.length === 0 ? (

No hay suscripciones

) : (
{subs.map((s: any) => { const st = statusConfig[s.status] || statusConfig.pending; const StIcon = st.icon; return ( ); })}
ClienteRFCPlanEstadoMontoFrecuenciaSiguiente pagoCreada
{s.tenant?.nombre || '—'} {s.tenant?.rfc || '—'} {s.plan} {st.label} ${Number(s.amount).toLocaleString('es-MX', { minimumFractionDigits: 2 })} {s.frequency} {s.currentPeriodEnd ? new Date(s.currentPeriodEnd).toLocaleDateString('es-MX') : '—'} {new Date(s.createdAt).toLocaleDateString('es-MX')}
)}
); } // ============================================================================ // Frequency Toggle // ============================================================================ function FrequencyToggle({ value, onChange }: { value: 'monthly' | 'annual'; onChange: (v: 'monthly' | 'annual') => void }) { return (
); } // ============================================================================ // Main Page // ============================================================================ export default function SuscripcionPage() { const { user } = useAuthStore(); // Admin global ve todas las suscripciones if (isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles)) { return ( <>
); } const { data: subscription, isLoading } = useSubscription(user?.tenantId); const { data: plans = [] } = usePlans(); const { data: payments } = usePaymentHistory(user?.tenantId); const startTrial = useStartTrial(); const subscribeMe = useSubscribeMe(); const changePlan = useChangeMyPlan(); const cancelSub = useCancelMySubscription(); const generateLink = useGeneratePaymentLink(); const upgradeMe = useUpgradeMe(); const cancelUpgrade = useCancelPendingUpgrade(); const reactivateSub = useReactivateMe(); const [pickerFrequency, setPickerFrequency] = useState<'monthly' | 'annual'>('monthly'); const [pickerSelected, setPickerSelected] = useState(null); const [changeModalOpen, setChangeModalOpen] = useState(false); const [changeFreq, setChangeFreq] = useState<'monthly' | 'annual'>('monthly'); const [changeSelected, setChangeSelected] = useState(null); const [cancelModalOpen, setCancelModalOpen] = useState(false); // Estado derivado const status = subscription?.status || null; const daysUntilEnd = getDaysUntil(subscription?.currentPeriodEnd); const isExpired = daysUntilEnd !== null && daysUntilEnd <= 0; const isTrial = status === 'trial' && !isExpired; const isTrialExpired = status === 'trial_expired' || (status === 'trial' && isExpired); const isCancelledInPeriod = status === 'cancelled' && !isExpired; const isCancelledExpired = status === 'cancelled' && isExpired; const isActive = status === 'authorized' && !isExpired; const isPending = status === 'pending'; const hasUsedTrial = !!subscription && ['trial', 'trial_expired', 'trial_converted'].includes(status || ''); const needsNewSubscription = !subscription || isTrialExpired || isCancelledExpired; const hasPendingChange = !!subscription?.pendingPlan && !!subscription?.pendingEffectiveAt; const hasPendingUpgrade = !!subscription?.upgradePreferenceId && !!subscription?.upgradeTargetPlan; // Handlers const handleStartTrial = async () => { if (!pickerSelected) return; try { await startTrial.mutateAsync({ plan: pickerSelected, frequency: pickerFrequency }); } catch (err: any) { alert(err?.response?.data?.message || err?.message || 'Error al iniciar trial'); } }; const handleSubscribe = async () => { if (!pickerSelected) return; try { const result = await subscribeMe.mutateAsync({ plan: pickerSelected, frequency: pickerFrequency }); window.open(result.paymentUrl, '_blank'); } catch (err: any) { alert(err?.response?.data?.message || err?.message || 'Error al crear suscripción'); } }; const openChangeModal = () => { setChangeFreq((subscription?.frequency as 'monthly' | 'annual') || 'monthly'); setChangeSelected(subscription?.plan || null); setChangeModalOpen(true); }; // Clasifica el cambio para decidir endpoint: upgrade inmediato (con cobro prorateado) // vs scheduled change (al próximo período). Upgrade aplica solo si se mantiene la // frecuencia actual Y el nuevo plan es más caro que el actual para esa frecuencia. const classifyChange = (plan: string, freq: 'monthly' | 'annual'): 'upgrade' | 'scheduled' | 'noop' => { if (!subscription) return 'scheduled'; if (plan === subscription.plan && freq === subscription.frequency) return 'noop'; if (freq !== subscription.frequency) return 'scheduled'; const newPrice = Number(plans.find((p) => p.plan === plan && p.frequency === freq)?.amount ?? 0); const currentPrice = Number(subscription.amount); return newPrice > currentPrice ? 'upgrade' : 'scheduled'; }; const handleConfirmChange = async () => { if (!changeSelected) return; const kind = classifyChange(changeSelected, changeFreq); if (kind === 'noop') { setChangeModalOpen(false); return; } try { if (kind === 'upgrade') { const result = await upgradeMe.mutateAsync(changeSelected); setChangeModalOpen(false); window.open(result.checkoutUrl, '_blank'); } else { await changePlan.mutateAsync({ plan: changeSelected, frequency: changeFreq }); setChangeModalOpen(false); } } catch (err: any) { alert(err?.response?.data?.message || err?.message || 'Error al cambiar plan'); } }; const handleCancelPendingUpgrade = async () => { try { await cancelUpgrade.mutateAsync(); } catch (err: any) { alert(err?.response?.data?.message || err?.message || 'Error al cancelar upgrade'); } }; const handleReactivate = async () => { try { const result = await reactivateSub.mutateAsync(); window.open(result.paymentUrl, '_blank'); } catch (err: any) { alert(err?.response?.data?.message || err?.message || 'Error al reactivar suscripción'); } }; const handleCancel = async () => { try { await cancelSub.mutateAsync(); setCancelModalOpen(false); } catch (err: any) { alert(err?.response?.data?.message || err?.message || 'Error al cancelar'); } }; const handleGeneratePaymentLink = async () => { if (!user?.tenantId) return; try { const result = await generateLink.mutateAsync(user.tenantId); window.open(result.paymentUrl, '_blank'); } catch (err: any) { alert(err?.response?.data?.message || err?.message || 'Error al generar link'); } }; // ======================================================================== // Render // ======================================================================== return ( <>
{isLoading && ( Cargando... )} {!isLoading && ( <> {/* Banners de estado */} {isTrial && (

Estás en prueba gratuita

Te quedan {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''} para probar todas las funciones. Contrata un plan antes del {formatDate(subscription?.currentPeriodEnd)} para continuar sin interrupciones.

)} {isTrialExpired && (

Tu prueba gratuita terminó

Elige un plan abajo para continuar usando Horux360.

)} {isCancelledInPeriod && (

Suscripción cancelada

Seguirás teniendo acceso hasta el {formatDate(subscription?.currentPeriodEnd)}. Puedes reactivarla antes de esa fecha para no perder la continuidad; el primer cobro se hará al iniciar el próximo período.

)} {isCancelledExpired && (

Suscripción vencida

Elige un plan abajo para reactivar tu cuenta.

)} {hasPendingChange && (

Cambio de plan programado

Tu plan cambiará a {PLAN_LABELS[subscription!.pendingPlan!]} ({subscription!.pendingFrequency === 'annual' ? 'anual' : 'mensual'}) el {formatDate(subscription!.pendingEffectiveAt)}.

)} {hasPendingUpgrade && (

Upgrade pendiente de pago

Estás por cambiar a {PLAN_LABELS[subscription!.upgradeTargetPlan!]}. Completa el pago prorateado en MercadoPago para activar el plan nuevo de inmediato.

)} {isPending && !isExpired && (

Pago pendiente

Tu suscripción está creada pero aún no autorizaste el pago en MercadoPago.

)} {/* Current subscription card — solo cuando hay sub activa/en-periodo */} {subscription && !needsNewSubscription && (
Tu Suscripción {status && ( (() => { const st = statusConfig[status] || statusConfig.pending; const StIcon = st.icon; return ( {st.label} ); })() )}

Plan

{PLAN_LABELS[subscription.plan] || subscription.plan}

Monto

{Number(subscription.amount) === 0 ? 'Gratis' : formatAmount(subscription.amount)}

Frecuencia

{subscription.frequency === 'annual' ? 'Anual' : 'Mensual'}

{isTrial && ( )} {isCancelledInPeriod && ( )} {(isActive || isPending) && ( )} {(isActive || isPending || isTrial) && ( )}
)} {/* Billing period */} {subscription && subscription.currentPeriodEnd && !needsNewSubscription && ( Período de Facturación

Inicio del período

{formatDate(subscription.currentPeriodStart)}

Fin del período

{formatDate(subscription.currentPeriodEnd)}

{isTrial ? 'Termina trial en' : 'Próximo pago'}

{daysUntilEnd !== null && daysUntilEnd > 0 ? (

En {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''}

) : (

Vencido

)}
)} {/* Picker — primera vez O después de trial/cancel vencido */} {needsNewSubscription && ( {subscription ? 'Elige un plan para continuar' : 'Elige tu plan'}

Todos los planes incluyen acceso completo a la plataforma. Puedes cambiar o cancelar cuando quieras.

{!hasUsedTrial && ( )}
{!pickerSelected && (

Selecciona un plan arriba para continuar

)}
)} {/* Payment history — siempre visible si hay pagos */} {payments && payments.length > 0 && ( Historial de Pagos
{payments.map((p) => ( ))}
Fecha Monto Estado Método
{new Date(p.createdAt).toLocaleDateString('es-MX')} {formatAmount(p.amount)} {p.status === 'approved' && } {p.status === 'rejected' && } {p.status !== 'approved' && p.status !== 'rejected' && } {p.status === 'approved' ? 'Aprobado' : p.status === 'rejected' ? 'Rechazado' : 'Pendiente'} {p.paymentMethod === 'bank_transfer' ? 'Transferencia' : p.paymentMethod || '—'}
)} )} {/* Change plan modal */} Cambiar plan Los upgrades (plan más caro, misma frecuencia) se cobran ahora por la diferencia prorateada y se activan de inmediato. Los downgrades y cambios de frecuencia se aplican al iniciar tu próximo período ({formatDate(subscription?.currentPeriodEnd)}).
{changeSelected && (() => { const kind = classifyChange(changeSelected, changeFreq); if (kind === 'upgrade') { return (
Este cambio es un upgrade. Al confirmar, abriremos MercadoPago para cobrar el monto prorateado de los días restantes del período actual. El plan nuevo se activa en cuanto se confirma el pago.
); } if (kind === 'scheduled') { return (
Este cambio se aplicará el {formatDate(subscription?.currentPeriodEnd)}. Sin cargo adicional ahora.
); } return null; })()}
{/* Cancel confirmation modal */} ¿Cancelar suscripción? Conservarás acceso a todas las funciones hasta el {formatDate(subscription?.currentPeriodEnd)}. Después de esa fecha tendrás que elegir un nuevo plan para seguir usando Horux360.
); }