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

@@ -0,0 +1,191 @@
import { prisma } from '../config/database.js';
import { emailService } from './email/email.service.js';
import { getTenantOwnerEmail } from '../utils/memberships.js';
import crypto from 'crypto';
function generateToken(): string {
return crypto.randomBytes(32).toString('hex');
}
export async function createInvitation(params: {
tenantId: string;
invitedByUserId: string;
plan?: string;
durationDays: number;
}) {
const tenant = await prisma.tenant.findUnique({
where: { id: params.tenantId },
select: { nombre: true, rfc: true, plan: true },
});
if (!tenant) throw new Error('Tenant no encontrado');
// Verificar que no haya ya una invitación pendiente para este tenant
const existingPending = await prisma.trialInvitation.findFirst({
where: { tenantId: params.tenantId, status: 'pending' },
});
if (existingPending) {
throw new Error('Este tenant ya tiene una invitación de trial pendiente');
}
// Verificar que el tenant no tenga ya una suscripción activa del mismo plan
const existingSub = await prisma.subscription.findFirst({
where: {
tenantId: params.tenantId,
status: { in: ['authorized', 'pending', 'trial'] },
plan: (params.plan || 'business_control') as any,
},
});
if (existingSub) {
throw new Error(`Este tenant ya tiene una suscripción activa o en trial de ${params.plan || 'business_control'}`);
}
const token = generateToken();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días para aceptar
const invitation = await prisma.trialInvitation.create({
data: {
tenantId: params.tenantId,
invitedBy: params.invitedByUserId,
plan: params.plan || 'business_control',
durationDays: params.durationDays,
token,
expiresAt,
emailSentTo: null,
},
});
// Enviar email al owner (fire-and-forget)
const ownerEmail = await getTenantOwnerEmail(params.tenantId);
if (ownerEmail) {
await prisma.trialInvitation.update({
where: { id: invitation.id },
data: { emailSentTo: ownerEmail },
});
const acceptUrl = `${process.env.FRONTEND_URL || 'https://app.horux360.com'}/invitacion/trial/${token}`;
emailService.sendTrialInvitation(ownerEmail, {
despachoNombre: tenant.nombre,
plan: invitation.plan,
durationDays: invitation.durationDays,
acceptUrl,
expiresAt: expiresAt.toLocaleDateString('es-MX'),
}).catch((err: any) => console.error('[TrialInvitation] Email failed:', err.message));
}
return invitation;
}
export async function acceptInvitation(token: string, userId: string) {
const invitation = await prisma.trialInvitation.findUnique({
where: { token },
});
if (!invitation) throw new Error('Invitación no encontrada');
if (invitation.status !== 'pending') throw new Error(`Invitación ya ${invitation.status}`);
if (invitation.expiresAt < new Date()) {
await prisma.trialInvitation.update({
where: { id: invitation.id },
data: { status: 'expired' },
});
throw new Error('La invitación ha expirado');
}
// Verificar que el usuario sea owner del tenant
const membership = await prisma.tenantMembership.findFirst({
where: { userId, tenantId: invitation.tenantId, isOwner: true, active: true },
});
if (!membership) {
throw new Error('Solo el dueño del despacho puede aceptar esta invitación');
}
const trialEndsAt = new Date();
trialEndsAt.setDate(trialEndsAt.getDate() + invitation.durationDays);
const now = new Date();
await prisma.$transaction(async (tx) => {
// Actualizar tenant
await tx.tenant.update({
where: { id: invitation.tenantId },
data: {
plan: invitation.plan as any,
trialEndsAt,
},
});
// Cancelar cualquier subscription trial anterior genérica
await tx.subscription.updateMany({
where: { tenantId: invitation.tenantId, status: 'trial' },
data: { status: 'trial_converted' },
});
// Crear nueva subscription de trial con el plan específico
await tx.subscription.create({
data: {
tenantId: invitation.tenantId,
plan: invitation.plan as any,
status: 'trial',
amount: 0,
frequency: 'annual',
currentPeriodStart: now,
currentPeriodEnd: trialEndsAt,
},
});
// Marcar invitación como aceptada
await tx.trialInvitation.update({
where: { id: invitation.id },
data: { status: 'accepted', acceptedAt: now },
});
});
return { success: true, trialEndsAt, plan: invitation.plan, durationDays: invitation.durationDays };
}
export async function getInvitations(filters?: { tenantId?: string; status?: string }) {
const where: any = {};
if (filters?.tenantId) where.tenantId = filters.tenantId;
if (filters?.status) where.status = filters.status;
const invitations = await prisma.trialInvitation.findMany({
where,
orderBy: { createdAt: 'desc' },
});
// Enrich with tenant data
const tenantIds = [...new Set(invitations.map(i => i.tenantId))];
const tenants = await prisma.tenant.findMany({
where: { id: { in: tenantIds } },
select: { id: true, nombre: true, rfc: true },
});
const tenantMap = new Map(tenants.map(t => [t.id, t]));
return invitations.map(inv => ({
...inv,
tenant: tenantMap.get(inv.tenantId) || null,
}));
}
export async function getPendingInvitationForTenant(tenantId: string) {
const invitation = await prisma.trialInvitation.findFirst({
where: { tenantId, status: 'pending', expiresAt: { gt: new Date() } },
});
if (!invitation) return null;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true, rfc: true },
});
return { ...invitation, tenant };
}
export async function cancelInvitation(invitationId: string) {
const invitation = await prisma.trialInvitation.findUnique({
where: { id: invitationId },
});
if (!invitation) throw new Error('Invitación no encontrada');
if (invitation.status !== 'pending') throw new Error('Solo se pueden cancelar invitaciones pendientes');
return prisma.trialInvitation.update({
where: { id: invitationId },
data: { status: 'cancelled' },
});
}