Backend: - Notificación email al admin cuando llega primer pago aprobado (sin factura auto) - Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global - Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d) - Fix webhook MP: validación defensiva de x-signature header - Fix autocompleto RFCs: eliminado filtro por contribuyenteId - Fix autocompleto conceptos: eliminado filtro por contribuyenteId - SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds - SAT sync request reuse across jobs para evitar agotar cuota diaria - Typo fix MP_ACCESS_TOKEN en .env - Trial invitations system backend Frontend: - Nueva página /admin/facturas-pendientes con tabla y emisión manual - Métrica 'Facturas pendientes' en /clientes (clickable) - Navegación onboarding FIEL/CSD corregida - Sidebar themes sincronizados - Fix SAT portal migration scraper (NetIQ) - Trial invitation acceptance pages
543 lines
28 KiB
TypeScript
543 lines
28 KiB
TypeScript
'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<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 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 (
|
||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap">
|
||
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) {
|
||
return <Button disabled className="w-full">Plan actual</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 && (() => {
|
||
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>
|
||
);
|
||
})()}
|
||
|
||
{/* 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 (
|
||
<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' && <ActiveBadge />}
|
||
<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' && <ActiveBadge />}
|
||
<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'
|
||
? <ActiveBadge />
|
||
: (
|
||
<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' && <ActiveBadge />}
|
||
<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>
|
||
);
|
||
}
|