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' }, }); }