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
192 lines
6.1 KiB
TypeScript
192 lines
6.1 KiB
TypeScript
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' },
|
|
});
|
|
}
|