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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user