feat: facturación primer pago, fixes SAT/MP, autocompletado RFCs/conceptos

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
This commit is contained in:
Horux Dev
2026-05-09 21:56:42 +00:00
parent b00b677c54
commit 9f11a0ba39
70 changed files with 2801 additions and 609 deletions

View File

@@ -3,23 +3,18 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, cn } from '@horux/shared-ui';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/lib/api/client';
import { CheckCircle2, Server, Cloud, ArrowLeft, Clock, Building, Sparkles } from 'lucide-react';
import { CheckCircle2, ArrowLeft } from 'lucide-react';
type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
type PlanType = 'trial' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud';
export default function RegisterDespachoPage() {
const router = useRouter();
const { setUser, setTokens } = useAuthStore();
const [step, setStep] = useState(1);
const [verticalProfile, setVerticalProfile] = useState<VerticalProfile | null>(null);
const [selectedPlan, setSelectedPlan] = useState<PlanType | null>(null);
// Default 'annual' — sesgo intencional al cash-flow del negocio (10 meses
// = 17% descuento para el cliente, año completo cobrado upfront para nosotros).
const [billingFrequency, setBillingFrequency] = useState<'monthly' | 'annual'>('annual');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState({
@@ -37,7 +32,7 @@ export default function RegisterDespachoPage() {
const handleSubmit = async () => {
if (!form.acceptedTerms) { setError('Debes aceptar los términos y condiciones'); return; }
if (!verticalProfile || !selectedPlan) { setError('Completa todos los pasos'); return; }
if (!verticalProfile) { setError('Selecciona un tipo de despacho'); return; }
setLoading(true);
setError('');
try {
@@ -45,10 +40,6 @@ export default function RegisterDespachoPage() {
despacho: {
nombre: form.despachoNombre,
verticalProfile,
plan: selectedPlan,
// Solo mi_empresa(+) acepta monthly; el backend ignora frequency
// para los demás planes. Mandamos siempre el state para coherencia.
frequency: billingFrequency,
},
owner: {
nombre: form.ownerNombre,
@@ -58,13 +49,7 @@ export default function RegisterDespachoPage() {
});
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
// If paid plan with payment URL, redirect to MercadoPago
if (data.paymentUrl) {
window.location.href = data.paymentUrl;
} else {
router.push('/onboarding');
}
router.push('/onboarding');
} catch (err: any) {
setError(err.response?.data?.message || 'Error al registrar el despacho');
setStep(1);
@@ -85,8 +70,6 @@ export default function RegisterDespachoPage() {
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">1</span>
<span className="w-8 h-px bg-muted" />
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">2</span>
<span className="w-8 h-px bg-muted" />
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">3</span>
</div>
<CardTitle className="text-2xl font-bold">Crea tu cuenta</CardTitle>
<p className="text-sm text-muted-foreground mt-1">Plataforma para despachos profesionales</p>
@@ -118,297 +101,43 @@ export default function RegisterDespachoPage() {
}
// =================== STEP 2: Vertical Selection ===================
if (step === 2) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-3xl space-y-8 text-center">
<div>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground mb-4">
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
<span className="w-8 h-px bg-primary" />
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">2</span>
<span className="w-8 h-px bg-muted" />
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">3</span>
</div>
<h1 className="text-3xl font-bold">¿Qué tipo de despacho eres?</h1>
<p className="text-muted-foreground mt-2">Selecciona tu área profesional</p>
</div>
<div className="grid md:grid-cols-3 gap-4">
<button
onClick={() => { setVerticalProfile('CONTABLE'); setStep(3); }}
className="p-8 rounded-xl border-2 border-primary bg-card hover:bg-accent transition-all text-center space-y-3"
>
<div className="text-4xl">📊</div>
<h3 className="text-lg font-semibold">Contable</h3>
<p className="text-sm text-muted-foreground">Gestión fiscal, CFDI, IVA/ISR, SAT sync</p>
</button>
<div className="p-8 rounded-xl border-2 border-dashed border-muted bg-muted/30 text-center space-y-3 opacity-50 cursor-not-allowed">
<div className="text-4xl"></div>
<h3 className="text-lg font-semibold">Jurídico</h3>
<p className="text-sm text-muted-foreground">Próximamente</p>
</div>
<div className="p-8 rounded-xl border-2 border-dashed border-muted bg-muted/30 text-center space-y-3 opacity-50 cursor-not-allowed">
<div className="text-4xl">🏗</div>
<h3 className="text-lg font-semibold">Arquitectura</h3>
<p className="text-sm text-muted-foreground">Próximamente</p>
</div>
</div>
<button onClick={() => setStep(1)} className="text-sm text-muted-foreground underline">
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver al formulario
</button>
</div>
</div>
);
}
// =================== STEP 3: Subscription Selection ===================
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 py-8 px-4">
<div className="w-full max-w-7xl space-y-8">
<div className="text-center">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-3xl space-y-8 text-center">
<div>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground mb-4">
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
<span className="w-8 h-px bg-green-500" />
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
<span className="w-8 h-px bg-primary" />
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">3</span>
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">2</span>
</div>
<h1 className="text-3xl font-bold">Elige tu plan</h1>
<p className="text-muted-foreground mt-2">Empieza con el trial gratuito de 30 días o contrata un plan directo.</p>
<h1 className="text-3xl font-bold">¿Qué tipo de despacho eres?</h1>
<p className="text-muted-foreground mt-2">Selecciona tu área profesional</p>
</div>
{/* Toggle facturación mensual / anual (afecta solo Mi Empresa y Mi Empresa+) */}
<div className="flex justify-center">
<div className="inline-flex items-center gap-1 rounded-full border bg-muted/30 p-1">
<button
type="button"
onClick={() => setBillingFrequency('monthly')}
className={cn(
'px-4 py-1.5 rounded-full text-sm font-medium transition-colors',
billingFrequency === 'monthly'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
Mensual
</button>
<button
type="button"
onClick={() => setBillingFrequency('annual')}
className={cn(
'px-4 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-2',
billingFrequency === 'annual'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
Anual
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded-full',
billingFrequency === 'annual'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-muted text-muted-foreground'
)}>
Ahorra 17%
</span>
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Trial Gratuito */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'trial' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('trial')}
<div className="grid md:grid-cols-3 gap-4">
<button
onClick={() => { setVerticalProfile('CONTABLE'); handleSubmit(); }}
disabled={loading}
className="p-8 rounded-xl border-2 border-primary bg-card hover:bg-accent transition-all text-center space-y-3"
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-green-100 dark:bg-green-900 rounded-full p-3 w-fit mb-2">
<Clock className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<CardTitle className="text-lg">Trial Gratuito</CardTitle>
<p className="text-xs text-muted-foreground">Prueba sin compromiso</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<div className="text-2xl font-bold">$0</div>
<p className="text-xs text-muted-foreground">30 días</p>
<p className="text-xs text-muted-foreground mt-1">Sin tarjeta</p>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Hasta 3 RFCs</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>1 usuario</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Todas las funcionalidades</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en la nube</span></div>
</div>
</CardContent>
</Card>
{/* Mi Empresa */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'mi_empresa' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('mi_empresa')}
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-orange-100 dark:bg-orange-900 rounded-full p-3 w-fit mb-2">
<Building className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
<CardTitle className="text-lg">Mi Empresa</CardTitle>
<p className="text-xs text-muted-foreground">Para 1 contribuyente</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
{billingFrequency === 'annual' ? (
<>
<div className="text-2xl font-bold">$5,800</div>
<p className="text-xs text-muted-foreground">por año</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-1 font-medium">Equivale a 10 meses</p>
</>
) : (
<>
<div className="text-2xl font-bold">$580</div>
<p className="text-xs text-muted-foreground">mensual</p>
<p className="text-xs text-muted-foreground mt-1">o $5,800/año (10 meses)</p>
</>
)}
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>1 RFC</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>3 usuarios</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>50 timbres/mes</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en la nube</span></div>
</div>
</CardContent>
</Card>
{/* Mi Empresa + */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'mi_empresa_plus' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('mi_empresa_plus')}
>
<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">
<Sparkles className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<CardTitle className="text-lg">Mi Empresa +</CardTitle>
<p className="text-xs text-muted-foreground">Con IA + API</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
{billingFrequency === 'annual' ? (
<>
<div className="text-2xl font-bold">$9,000</div>
<p className="text-xs text-muted-foreground">por año</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-1 font-medium">Equivale a 10 meses</p>
</>
) : (
<>
<div className="text-2xl font-bold">$900</div>
<p className="text-xs text-muted-foreground">mensual</p>
<p className="text-xs text-muted-foreground mt-1">o $9,000/año (10 meses)</p>
</>
)}
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Todo Mi Empresa</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Lolita IA Fiscal</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>API de integración</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental</span></div>
</div>
</CardContent>
</Card>
{/* Business Control */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg relative',
selectedPlan === 'business_control' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('business_control')}
>
<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-lg">Business Control</CardTitle>
<p className="text-xs text-muted-foreground">Despachos contables</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<div className="text-2xl font-bold">$25,850</div>
<p className="text-xs text-muted-foreground">por año (IVA inc.)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por RFC extra</p>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>100 RFCs incluidos</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Usuarios ilimitados</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en tu servidor</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Servidor backup</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>API de integración</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental</span></div>
</div>
</CardContent>
</Card>
{/* Enterprise (business_cloud) */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'business_cloud' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('business_cloud')}
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-amber-100 dark:bg-amber-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-amber-600 dark:text-amber-400" />
</div>
<CardTitle className="text-lg">Enterprise</CardTitle>
<p className="text-xs text-muted-foreground">Despachos de alto volumen</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<div className="text-2xl font-bold">$43,000</div>
<p className="text-xs text-muted-foreground">por año (IVA inc.)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por RFC extra</p>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>100 RFCs incluidos</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>3M CFDIs por contribuyente</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Usuarios ilimitados</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en tu servidor</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Servidor backup</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental + API</span></div>
</div>
</CardContent>
</Card>
</div>
{error && <p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md text-center max-w-lg mx-auto">{error}</p>}
<div className="flex flex-col items-center gap-3">
<Button
onClick={handleSubmit}
disabled={!selectedPlan || loading}
size="lg"
className="px-12"
>
{loading ? 'Creando tu despacho...' : selectedPlan === 'trial' ? 'Comenzar trial gratuito' : 'Continuar al pago'}
</Button>
<button onClick={() => setStep(2)} className="text-sm text-muted-foreground underline">
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver
<div className="text-4xl">📊</div>
<h3 className="text-lg font-semibold">Contable</h3>
<p className="text-sm text-muted-foreground">Gestión fiscal, CFDI, IVA/ISR, SAT sync</p>
</button>
<div className="p-8 rounded-xl border-2 border-dashed border-muted bg-muted/30 text-center space-y-3 opacity-50 cursor-not-allowed">
<div className="text-4xl"></div>
<h3 className="text-lg font-semibold">Jurídico</h3>
<p className="text-sm text-muted-foreground">Próximamente</p>
</div>
<div className="p-8 rounded-xl border-2 border-dashed border-muted bg-muted/30 text-center space-y-3 opacity-50 cursor-not-allowed">
<div className="text-4xl">🏗</div>
<h3 className="text-lg font-semibold">Arquitectura</h3>
<p className="text-sm text-muted-foreground">Próximamente</p>
</div>
</div>
{error && <p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md max-w-lg mx-auto">{error}</p>}
<button onClick={() => setStep(1)} className="text-sm text-muted-foreground underline">
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver al formulario
</button>
</div>
</div>
);