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

@@ -2,9 +2,10 @@
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { CheckCircle2, Server, Cloud, Clock, ExternalLink, CreditCard } from 'lucide-react';
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';
@@ -37,7 +38,7 @@ 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'>(null);
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
@@ -45,6 +46,12 @@ export default function PlanesDespachoPage() {
// 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')
@@ -58,6 +65,20 @@ export default function PlanesDespachoPage() {
.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;
@@ -153,6 +174,22 @@ export default function PlanesDespachoPage() {
}
}
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">
@@ -242,6 +279,28 @@ export default function PlanesDespachoPage() {
</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;