'use client'; import { useEffect, useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui'; import { CheckCircle2, Server, Cloud, Clock, ExternalLink, CreditCard, Gift } from 'lucide-react'; import { apiClient } from '@/lib/api/client'; import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription'; import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations'; import { useAuthStore } from '@/stores/auth-store'; type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom'; type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus'; interface SubscriptionInfo { status: string; plan: string; amount: number; currentPeriodStart: string | null; currentPeriodEnd: string | null; } interface PlanInfo { plan: Despachoplan; dbMode: string; trialEndsAt: string | null; isTrialActive: boolean; subscription: SubscriptionInfo | null; } function daysUntil(isoDate: string): number { const diff = new Date(isoDate).getTime() - Date.now(); return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24))); } type Frequency = 'monthly' | 'annual'; export default function PlanesDespachoPage() { const { user } = useAuthStore(); const [planInfo, setPlanInfo] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(null); const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); // Toggle mensual/anual solo aplica a Mi Empresa y Mi Empresa+. Business // Control y Enterprise siempre se cobran anual. Default monthly para // bajar friction inicial; el descuento del 17% al pagar anual se // muestra como CTA secundario. const [meFreq, setMeFreq] = useState('monthly'); const [mePlusFreq, setMePlusFreq] = useState('monthly'); const [pendingInvitation, setPendingInvitation] = useState<{ id: string; plan: string; durationDays: number; token: string; } | null>(null); const fetchPlan = () => { apiClient.get('/despachos/me/plan') .then(res => setPlanInfo(res.data)) .catch(() => setPlanInfo(null)); }; useEffect(() => { setLoading(true); apiClient.get('/despachos/me/plan') .then(res => setPlanInfo(res.data)) .catch(() => setPlanInfo(null)) .finally(() => setLoading(false)); // Cargar invitación de trial pendiente getPendingInvitation() .then((inv) => { if (inv && inv.status === 'pending') { setPendingInvitation({ id: inv.id, plan: inv.plan, durationDays: inv.durationDays, token: inv.token, }); } }) .catch(() => {}); }, []); const currentPlan = planInfo?.plan ?? null; const trialDaysLeft = planInfo?.trialEndsAt ? daysUntil(planInfo.trialEndsAt) : 0; const hasPaidPlan = currentPlan === 'business_control' || currentPlan === 'business_cloud' || currentPlan === 'mi_empresa' || currentPlan === 'mi_empresa_plus'; const isCustomPlan = currentPlan === 'custom'; // El usuario puede cancelar si tiene una suscripción que aún corre (paid, trial, // custom). Si ya está cancelada o expirada, no hay nada que cancelar. const subStatus = planInfo?.subscription?.status ?? null; const hasActiveSub = subStatus != null && subStatus !== 'cancelled' && subStatus !== 'trial_expired'; /** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su * propio toggle; el resto (business_*) siempre annual. */ function frequencyFor(plan: PaidPlan): Frequency { if (plan === 'mi_empresa') return meFreq; if (plan === 'mi_empresa_plus') return mePlusFreq; return 'annual'; } async function handleContratar(plan: PaidPlan) { const frequency = frequencyFor(plan); setBusy(plan); setMessage(null); try { // Sin sub activa: subscribe directo → MP (preapproval del plan completo). const result = await subscribeMe({ plan, frequency }); window.open(result.paymentUrl, '_blank'); setMessage({ kind: 'ok', text: 'Abrimos el pago de MercadoPago en otra pestaña. Al completar regresa aquí.' }); } catch (err: any) { const msg: string = err?.response?.data?.message || err?.message || ''; if (!/Ya existe una suscripci/i.test(msg)) { setMessage({ kind: 'err', text: msg || 'Error al contratar el plan' }); setBusy(null); return; } // Hay sub activa en otro plan. Intentamos upgrade (prorrateado, MP) primero // — si el backend determina que es downgrade o misma frecuencia más barata, // rechaza y caemos a cambio programado para fin de período. try { const upgradeResult = await upgradeMe(plan); window.open(upgradeResult.checkoutUrl, '_blank'); const monto = Number(upgradeResult.proratedAmount).toLocaleString('es-MX', { minimumFractionDigits: 2 }); setMessage({ kind: 'ok', text: `Upgrade a ${plan} — abrimos el cobro prorrateado de $${monto} en MercadoPago.` }); } catch (upErr: any) { try { await changeMyPlan({ plan, frequency }); setMessage({ kind: 'ok', text: 'Cambio de plan programado para el final del período actual (sin cobro inmediato).' }); fetchPlan(); } catch (changeErr: any) { setMessage({ kind: 'err', text: changeErr?.response?.data?.message || changeErr?.message || 'Error al cambiar el plan' }); } } } finally { setBusy(null); } } /** * Genera un link de pago one-off en MercadoPago para el monto vigente de la * suscripción actual. Útil cuando: (a) el usuario quiere pagar el período * actual antes de que venza, (b) la sub está en `pending` y nunca se ejecutó * el primer cobro, (c) custom plans con monto manual. */ async function handlePagarAhora() { if (!user?.tenantId) return; setBusy('pay-now'); setMessage(null); try { const result = await generatePaymentLink(user.tenantId); window.open(result.paymentUrl, '_blank'); setMessage({ kind: 'ok', text: 'Abrimos el link de pago de MercadoPago en otra pestaña. Al completar regresa aquí.' }); } catch (err: any) { setMessage({ kind: 'err', text: err?.response?.data?.message || err?.message || 'Error al generar el link de pago' }); } finally { setBusy(null); } } async function handleCancelar() { if (!confirm('Seguro que quieres cancelar la suscripcion? Conservaras acceso hasta el final del periodo pagado.')) return; setBusy('cancel'); setMessage(null); try { await cancelMySubscription(); setMessage({ kind: 'ok', text: 'Suscripcion cancelada. Acceso activo hasta el final del periodo actual.' }); fetchPlan(); } catch (err: any) { setMessage({ kind: 'err', text: err?.response?.data?.message || err?.message || 'Error al cancelar' }); } finally { setBusy(null); } } async function handleAcceptInvitation() { if (!pendingInvitation) return; setBusy('accept-invite'); setMessage(null); try { const result = await acceptInvitation(pendingInvitation.token); setMessage({ kind: 'ok', text: `¡Activado! Tienes ${result.durationDays} días de Business Control Prueba.` }); setPendingInvitation(null); fetchPlan(); } catch (err: any) { setMessage({ kind: 'err', text: err?.response?.data?.message || err?.message || 'Error al activar la invitación' }); } finally { setBusy(null); } } function ActiveBadge() { return (
Plan actual
); } /** * Toggle binario Mensual/Anual. La opción anual va resaltada con un * pequeño badge "−17%" para enfocar el descuento. */ function FrequencyToggle({ value, onChange }: { value: Frequency; onChange: (v: Frequency) => void }) { return (
); } function PlanActionButton({ plan }: { plan: PaidPlan }) { const isCurrent = currentPlan === plan; if (isCurrent) { return ; } const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar'; return ( ); } return (

Planes Horux Despachos

Tres planes: Mi Empresa para usuarios individuales, Business Control y Enterprise para despachos.

{/* Banner Custom — plan asignado por admin, sin cobro */} {!loading && isCustomPlan && (
Plan Custom — sin cobro, vigencia indefinida
Tu cuenta está bajo un plan especial asignado por tu administrador. Contacta a soporte si necesitas cambiar de plan.
)} {/* Trial banner */} {!loading && planInfo?.isTrialActive && (
Periodo de prueba activo — {trialDaysLeft} {trialDaysLeft === 1 ? 'dia restante' : 'dias restantes'}
)} {/* Banner de invitación de trial pendiente */} {!loading && pendingInvitation && (
Invitación especial — Business Control Prueba
Tienes una invitación para probar Business Control por {pendingInvitation.durationDays} días con todas las funciones.
)} {/* Banner de suscripción activa */} {!loading && planInfo?.subscription && hasPaidPlan && (() => { const sub = planInfo.subscription; const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null; const fechaFormato = periodEndDate ? periodEndDate.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' }) : null; const montoFmt = sub.amount.toLocaleString('es-MX'); return (
Suscripcion activa — { sub.plan === 'business_control' ? 'Business Control' : sub.plan === 'business_cloud' ? 'Enterprise' : sub.plan === 'mi_empresa_plus' ? 'Mi Empresa +' : 'Mi Empresa' }
Proxima renovacion{fechaFormato ? ` el ${fechaFormato}` : ''}: ${montoFmt}/año
); })()} {/* Botón "Pagar mi período actual" — visible cuando la sub corre y hay un monto > 0 que cobrar. Crea una MP Preference one-off por el monto actual (custom $10, paid plan, lo que sea). Útil para pre-pagar antes del cobro automático o cuando no hay preapproval recurrente activo. */} {!loading && hasActiveSub && planInfo?.subscription && Number(planInfo.subscription.amount) > 0 && (() => { const sub = planInfo.subscription!; const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null; const fechaFmt = periodEnd ? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' }) : null; const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null; const montoFmt = Number(sub.amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); return (
Pagar mi período actual — ${montoFmt}
{dias != null && dias > 0 ? `Tu período termina ${dias === 1 ? 'mañana' : `en ${dias} días`}${fechaFmt ? ` (${fechaFmt})` : ''}.` : fechaFmt ? `Tu período terminó el ${fechaFmt}.` : 'Renueva tu suscripción.'}
); })()} {/* Toast de resultado */} {message && (
{message.text}
)}
{/* Mi Empresa */} {currentPlan === 'mi_empresa' && }
Mi Empresa

Para una sola empresa

${meFreq === 'monthly' ? '580' : '5,800'}

{meFreq === 'monthly' ? 'por mes (IVA incluido)' : 'por año (IVA incluido)'}

{meFreq === 'monthly' ? (

o $5,800/año (ahorras 17%)

) : (

Pagas 10 meses en lugar de 12

)}
1 RFC
3 usuarios
Hasta 1,000,000 CFDIs
Base de datos en la nube
Dashboard, CFDI, IVA/ISR, alertas, calendario
Reportes, conciliación, documentos, facturación
50 timbres/mes incluidos
{/* Mi Empresa + */} {currentPlan === 'mi_empresa_plus' && }
Mi Empresa +

Mi Empresa con API y Lolita IA

${mePlusFreq === 'monthly' ? '900' : '9,000'}

{mePlusFreq === 'monthly' ? 'por mes (IVA incluido)' : 'por año (IVA incluido)'}

{mePlusFreq === 'monthly' ? (

o $9,000/año (ahorras 17%)

) : (

Pagas 10 meses en lugar de 12

)}
1 RFC
3 usuarios
Hasta 1,000,000 CFDIs
Base de datos en la nube
Dashboard, CFDI, IVA/ISR, alertas, calendario
Reportes, conciliación, documentos, facturación
50 timbres/mes incluidos
API REST incluida
Lolita IA agente fiscal
{/* Business Control */} {currentPlan === 'business_control' ? : (
Más popular
) }
Business Control

Tu servidor, tus datos

$25,850

por año (IVA incluido)

+ $45/mes por cada RFC adicional sobre 100

Hasta 100 RFCs
Usuarios ilimitados
Hasta 1,000,000 CFDIs por contribuyente
Servidor local con backup
Control total de tus datos
Dashboard, CFDI, IVA/ISR, alertas, calendario
Reportes, conciliación, documentos, facturación, API
{/* Enterprise (key interna: business_cloud) */} {currentPlan === 'business_cloud' && }
Enterprise

Despachos grandes con alto volumen

$43,000

por año (IVA incluido)

+ $45/mes por cada RFC adicional sobre 100

Hasta 100 RFCs
Usuarios ilimitados
Hasta 3,000,000 CFDIs por contribuyente
Servidor local con backup
Backups automáticos en la nube
Dashboard, CFDI, IVA/ISR, alertas, calendario
Reportes, conciliación, documentos, facturación, API
Soporte prioritario
{/* Cancelar — visible para cualquier suscripción aún corriendo (paid, trial, custom). No se muestra si ya está cancelada o expirada. */} {hasActiveSub && (
)}
); }