Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx
Horux Dev 7df27ce66d chore: catálogo obligaciones, cierre automático, fixes SAT y facturación
- Catálogo de obligaciones fiscales expandido a 30 entradas con campo requierePago.
- Soporte de frecuencia cuatrimestral en obligaciones y declaraciones.
- Automatización de cierre de obligaciones fiscales desde Documentos › Declaraciones.
- Nuevas tablas obligacion_evidencias, obligacion_periodos estados y declaracion_obligaciones.
- Nuevo servicio obligacion-evidencias.service.ts y endpoints REST.
- Refactor de declaraciones.service.ts para vincular obligaciones y crear evidencias.
- Notificaciones por email para evidencias de obligaciones.
- Adjuntar PDFs en correo de declaración subida.
- Fix drill-down de CFDIs: carga completa al visualizar.
- Fix sincronización SAT: tipos P/N, UUID case-insensitive, no reutilizar requestId.
- Fix suscripciones pending en /configuracion/planes-despacho.
- Fix sugerencias de Clave Producto SAT: importar catálogo y robustecer autocomplete.
- Quitar toggle manual de completado en Configuración › Obligaciones fiscales › Tareas.
- Scripts de soporte para Demo Ventas y utilerías (change-user-email, resend-welcome, import-clave-prod-serv).
- Documentación de cambios en docs/CAMBIOS-2026-05-04.md.
2026-06-22 04:53:59 +00:00

602 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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';
import { getSubscriptionState } from '@horux/shared';
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;
planPrice: number | null;
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<PlanInfo | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<null | PaidPlan | 'cancel' | 'pay-now' | 'accept-invite'>(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<Frequency>('monthly');
const [mePlusFreq, setMePlusFreq] = useState<Frequency>('monthly');
const [pendingInvitation, setPendingInvitation] = useState<{
id: string;
plan: string;
durationDays: number;
token: string;
} | null>(null);
const fetchPlan = () => {
apiClient.get<PlanInfo>('/despachos/me/plan')
.then(res => setPlanInfo(res.data))
.catch(() => setPlanInfo(null));
};
useEffect(() => {
setLoading(true);
apiClient.get<PlanInfo>('/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 subState = planInfo?.subscription ? getSubscriptionState(planInfo.subscription) : null;
const hasActiveSub = subState?.isActive || subState?.isTrial || subState?.isCancelledInPeriod || false;
// Estados en los que se puede generar un link de pago (incluye trial, vencido y pending).
const isPayableStatus = subStatus === 'trial'
|| subStatus === 'trial_expired'
|| subStatus === 'pending'
|| hasActiveSub;
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan && subState?.isActive === true;
/** 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 {
// Si el plan actual está pendiente de pago, solo regeneramos el link de pago.
if (currentPlan === plan && subState?.isPending) {
return await handlePagarAhora();
}
// Si tiene una sub pendiente en otro plan, no permitir cambiar hasta pagar.
if (subState?.isPending) {
setMessage({ kind: 'err', text: 'Completa el pago del plan actual antes de cambiar de plan.' });
return;
}
// 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 CurrentPlanBadge({ pending }: { pending?: boolean }) {
return (
<div className={`absolute -top-3 left-1/2 -translate-x-1/2 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap ${pending ? 'bg-yellow-600' : 'bg-green-600'}`}>
{pending ? 'Plan actual — pendiente' : 'Plan actual'}
</div>
);
}
/**
* 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 (
<div className="flex bg-muted rounded-lg p-1 text-xs font-medium">
<button
type="button"
onClick={() => onChange('monthly')}
className={`flex-1 py-1.5 rounded-md transition-colors ${value === 'monthly' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Mensual
</button>
<button
type="button"
onClick={() => onChange('annual')}
className={`flex-1 py-1.5 rounded-md transition-colors flex items-center justify-center gap-1 ${value === 'annual' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Anual <span className="text-emerald-600 dark:text-emerald-400 text-[10px] font-bold">17%</span>
</button>
</div>
);
}
function PlanActionButton({ plan }: { plan: PaidPlan }) {
const isCurrent = currentPlan === plan;
if (isCurrent && isCurrentPlanPaid) {
return <Button disabled className="w-full">Plan actual</Button>;
}
if (isCurrent) {
return (
<Button
className="w-full"
onClick={() => handleContratar(plan)}
disabled={busy === plan}
>
{busy === plan ? 'Procesando...' : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
Pagar este plan
</>
)}
</Button>
);
}
const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar';
return (
<Button
className="w-full"
onClick={() => handleContratar(plan)}
disabled={busy === plan}
>
{busy === plan ? 'Procesando...' : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
{label}
</>
)}
</Button>
);
}
return (
<div className="p-6 max-w-5xl mx-auto space-y-8">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">Planes Horux Despachos</h1>
<p className="text-muted-foreground">Tres planes: Mi Empresa para usuarios individuales, Business Control y Enterprise para despachos.</p>
</div>
{/* Banner Custom — plan asignado por admin, sin cobro */}
{!loading && isCustomPlan && (
<div className="flex items-start gap-3 bg-pink-50 dark:bg-pink-950 border border-pink-200 dark:border-pink-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<CheckCircle2 className="h-5 w-5 text-pink-600 dark:text-pink-400 flex-shrink-0 mt-0.5" />
<div className="text-sm space-y-0.5">
<div className="font-semibold text-pink-800 dark:text-pink-300">
Plan Custom sin cobro, vigencia indefinida
</div>
<div className="text-pink-700 dark:text-pink-400">
Tu cuenta está bajo un plan especial asignado por tu administrador.
Contacta a soporte si necesitas cambiar de plan.
</div>
</div>
</div>
)}
{/* Trial banner */}
{!loading && planInfo?.isTrialActive && (
<div className="flex items-center gap-3 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<Clock className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<div className="text-sm">
<span className="font-semibold text-amber-800 dark:text-amber-300">Periodo de prueba activo</span>
<span className="text-amber-700 dark:text-amber-400"> {trialDaysLeft} {trialDaysLeft === 1 ? 'dia restante' : 'dias restantes'}</span>
</div>
</div>
)}
{/* Banner de invitación de trial pendiente */}
{!loading && pendingInvitation && (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
<Gift className="h-6 w-6 text-purple-600 dark:text-purple-400 flex-shrink-0" />
<div className="flex-1 text-sm">
<div className="font-semibold text-purple-900 dark:text-purple-200">
Invitación especial Business Control Prueba
</div>
<div className="text-purple-700 dark:text-purple-400">
Tienes una invitación para probar Business Control por <strong>{pendingInvitation.durationDays} días</strong> con todas las funciones.
</div>
</div>
<Button
onClick={handleAcceptInvitation}
disabled={busy === 'accept-invite'}
className="w-full sm:w-auto"
>
{busy === 'accept-invite' ? 'Activando...' : 'Activar ahora'}
</Button>
</div>
)}
{/* Banner de suscripción activa */}
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isActive && (() => {
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 (
<div className="flex items-start gap-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div className="text-sm space-y-0.5">
<div className="font-semibold text-green-800 dark:text-green-300">
Suscripcion activa {
sub.plan === 'business_control' ? 'Business Control'
: sub.plan === 'business_cloud' ? 'Enterprise'
: sub.plan === 'mi_empresa_plus' ? 'Mi Empresa +'
: 'Mi Empresa'
}
</div>
<div className="text-green-700 dark:text-green-400">
Proxima renovacion{fechaFormato ? ` el ${fechaFormato}` : ''}: <strong>${montoFmt}/año</strong>
</div>
</div>
</div>
);
})()}
{/* Banner de suscripción pendiente */}
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isPending && (
<div className="flex items-start gap-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<Clock className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<div className="text-sm space-y-0.5">
<div className="font-semibold text-yellow-800 dark:text-yellow-300">
Suscripción pendiente de pago
</div>
<div className="text-yellow-700 dark:text-yellow-400">
Tu suscripción aún no está activa. Completa el pago para evitar la suspensión del servicio.
</div>
</div>
</div>
)}
{/* Banner de trial vencido */}
{!loading && subStatus === 'trial_expired' && hasPaidPlan && (
<div className="flex items-start gap-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<Clock className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<span className="font-semibold text-red-800 dark:text-red-300">Tu período de prueba terminó</span>
<span className="text-red-700 dark:text-red-400"> elige un plan o paga el plan actual para recuperar el acceso.</span>
</div>
</div>
)}
{/* Botón "Pagar mi período actual" — visible cuando se puede pagar y hay
un monto definido (subscription.amount > 0 o precio de catálogo).
Crea una MP Preference one-off por el monto actual. */}
{!loading && isPayableStatus && planInfo?.subscription && (() => {
const sub = planInfo.subscription!;
const effectiveAmount = Number(sub.amount) > 0 ? Number(sub.amount) : (planInfo.planPrice ?? 0);
if (!effectiveAmount) return null;
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 = effectiveAmount.toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
<CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="flex-1 text-sm">
<div className="font-semibold text-blue-900 dark:text-blue-200">
Pagar mi período actual ${montoFmt}
</div>
<div className="text-blue-700 dark:text-blue-400">
{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.'}
</div>
</div>
<Button
onClick={handlePagarAhora}
disabled={busy === 'pay-now'}
className="w-full sm:w-auto"
>
{busy === 'pay-now' ? 'Generando link…' : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
Pagar ahora
</>
)}
</Button>
</div>
);
})()}
{/* Toast de resultado */}
{message && (
<div
className={`max-w-3xl mx-auto rounded-lg px-4 py-3 text-sm ${
message.kind === 'ok'
? 'bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
: 'bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
}`}
>
{message.text}
</div>
)}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
{/* Mi Empresa */}
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'mi_empresa' && <CurrentPlanBadge pending={subState?.isPending} />}
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-emerald-100 dark:bg-emerald-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
</div>
<CardTitle className="text-xl">Mi Empresa</CardTitle>
<p className="text-sm text-muted-foreground">Para una sola empresa</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<FrequencyToggle value={meFreq} onChange={setMeFreq} />
<div className="text-center">
<div className="text-3xl font-bold">${meFreq === 'monthly' ? '580' : '5,800'}</div>
<p className="text-sm text-muted-foreground">{meFreq === 'monthly' ? 'por mes (IVA incluido)' : 'por año (IVA incluido)'}</p>
{meFreq === 'monthly' ? (
<p className="text-xs text-muted-foreground mt-1">o $5,800/año (ahorras 17%)</p>
) : (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 font-medium">Pagas 10 meses en lugar de 12</p>
)}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>1 RFC</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>3 usuarios</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Base de datos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>50 timbres/mes incluidos</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="mi_empresa" /></div>
</CardContent>
</Card>
{/* Mi Empresa + */}
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'mi_empresa_plus' && <CurrentPlanBadge pending={subState?.isPending} />}
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" />
</div>
<CardTitle className="text-xl">Mi Empresa +</CardTitle>
<p className="text-sm text-muted-foreground">Mi Empresa con API y Lolita IA</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<FrequencyToggle value={mePlusFreq} onChange={setMePlusFreq} />
<div className="text-center">
<div className="text-3xl font-bold">${mePlusFreq === 'monthly' ? '900' : '9,000'}</div>
<p className="text-sm text-muted-foreground">{mePlusFreq === 'monthly' ? 'por mes (IVA incluido)' : 'por año (IVA incluido)'}</p>
{mePlusFreq === 'monthly' ? (
<p className="text-xs text-muted-foreground mt-1">o $9,000/año (ahorras 17%)</p>
) : (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 font-medium">Pagas 10 meses en lugar de 12</p>
)}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>1 RFC</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>3 usuarios</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Base de datos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>50 timbres/mes incluidos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span><strong>API REST</strong> incluida</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span><strong>Lolita IA</strong> agente fiscal</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="mi_empresa_plus" /></div>
</CardContent>
</Card>
{/* Business Control */}
<Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}>
{currentPlan === 'business_control'
? <CurrentPlanBadge pending={subState?.isPending} />
: (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
Más popular
</div>
)
}
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-blue-100 dark:bg-blue-900 rounded-full p-3 w-fit mb-2">
<Server className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<CardTitle className="text-xl">Business Control</CardTitle>
<p className="text-sm text-muted-foreground">Tu servidor, tus datos</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<div className="text-center">
<div className="text-3xl font-bold">$25,850</div>
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Usuarios ilimitados</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs por contribuyente</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Servidor local con backup</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Control total de tus datos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación, API</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="business_control" /></div>
</CardContent>
</Card>
{/* Enterprise (key interna: business_cloud) */}
<Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'business_cloud' && <CurrentPlanBadge pending={subState?.isPending} />}
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<CardTitle className="text-xl">Enterprise</CardTitle>
<p className="text-sm text-muted-foreground">Despachos grandes con alto volumen</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<div className="text-center">
<div className="text-3xl font-bold">$43,000</div>
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Usuarios ilimitados</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 3,000,000 CFDIs por contribuyente</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Servidor local con backup</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Backups automáticos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación, API</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Soporte prioritario</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="business_cloud" /></div>
</CardContent>
</Card>
</div>
{/* Cancelar — visible para cualquier suscripción aún corriendo (paid, trial, custom).
No se muestra si ya está cancelada o expirada. */}
{hasActiveSub && (
<div className="text-center pt-4">
<button
type="button"
onClick={handleCancelar}
disabled={busy === 'cancel'}
className="text-sm text-muted-foreground hover:text-destructive underline underline-offset-4 disabled:opacity-50"
>
{busy === 'cancel' ? 'Cancelando...' : 'Cancelar suscripción'}
</button>
</div>
)}
</div>
);
}