import { prisma, tenantDb } from '../config/database.js'; import { DESPACHO_PLANS, type DespachoPlan } from '@horux/shared'; import { emailService } from './email/email.service.js'; import * as metabaseService from './metabase.service.js'; import { randomBytes } from 'crypto'; import bcrypt from 'bcryptjs'; export async function getAllTenants() { return prisma.tenant.findMany({ where: { active: true }, select: { id: true, nombre: true, rfc: true, plan: true, databaseName: true, createdAt: true, verticalProfile: true, codigoPostal: true, _count: { select: { memberships: { where: { active: true } } as any } }, subscriptions: { orderBy: { createdAt: 'desc' }, take: 1, select: { id: true, amount: true, currentPeriodEnd: true, status: true, }, }, }, orderBy: { nombre: 'asc' } }); } export async function getTenantById(id: string) { return prisma.tenant.findUnique({ where: { id }, select: { id: true, nombre: true, rfc: true, plan: true, databaseName: true, createdAt: true, verticalProfile: true, codigoPostal: true, } }); } export async function createTenant(data: { nombre: string; rfc: string; plan?: DespachoPlan; adminEmail: string; adminNombre: string; amount: number; /** Solo plan custom: primera fecha de pago (deadline para que el cliente * complete su primer cobro). Se persiste como Subscription.currentPeriodEnd. */ firstPaymentDueAt?: string | null; verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA'; codigoPostal?: string; }) { const plan = data.plan || 'trial'; // 1. Provision a dedicated database for this tenant const databaseName = await tenantDb.provisionDatabase(data.rfc); // 1b. Register tenant database in Metabase (non-blocking, logs errors only) metabaseService.registerDatabase({ nombre: data.nombre, dbName: databaseName, }).catch(err => console.error('[METABASE] Register failed:', err)); // 2. Create tenant record const isTrial = plan === 'trial'; const tenant = await prisma.tenant.create({ data: { nombre: data.nombre, rfc: data.rfc.toUpperCase(), plan, databaseName, dbMode: 'MANAGED', verticalProfile: data.verticalProfile || 'CONTABLE', codigoPostal: data.codigoPostal || undefined, trialEndsAt: isTrial ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : undefined, } }); // 3. Create or reuse admin user const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } }); if (!ownerRol) throw new Error('Rol owner no encontrado en la base de datos'); let user = await prisma.user.findUnique({ where: { email: data.adminEmail } }); let tempPassword: string | null = null; if (!user) { tempPassword = randomBytes(4).toString('hex'); // 8-char random const hashedPassword = await bcrypt.hash(tempPassword, 10); user = await prisma.user.create({ data: { email: data.adminEmail, passwordHash: hashedPassword, nombre: data.adminNombre, lastTenantId: tenant.id, }, }); } else { // User ya existe: actualizar lastTenantId y nombre si cambió await prisma.user.update({ where: { id: user.id }, data: { lastTenantId: tenant.id, ...(data.adminNombre && data.adminNombre !== user.nombre ? { nombre: data.adminNombre } : {}), }, }); } // Crea membership owner del user en su tenant (fase 4 multi-tenant). // Si ya existía (re-invite a otro tenant), reactivar. await prisma.tenantMembership.upsert({ where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } }, update: { rolId: ownerRol.id, isOwner: true, active: true }, create: { userId: user.id, tenantId: tenant.id, rolId: ownerRol.id, isOwner: true, active: true, }, }); // 4. Create initial subscription. Para plan custom, si admin proveyó // firstPaymentDueAt, lo guardamos como currentPeriodEnd — sirve como // deadline visible al cliente para realizar su primer pago. const firstPayment = plan === 'custom' && data.firstPaymentDueAt ? new Date(data.firstPaymentDueAt) : null; await prisma.subscription.create({ data: { tenantId: tenant.id, plan, status: 'pending', amount: data.amount, frequency: 'monthly', ...(firstPayment ? { currentPeriodStart: new Date(), currentPeriodEnd: firstPayment } : {}), }, }); // 5. Send welcome email to client (non-blocking) emailService.sendWelcome(data.adminEmail, { nombre: data.adminNombre, email: data.adminEmail, tempPassword, }).catch(err => console.error('[EMAIL] Welcome email failed:', err)); // 6. Send new client notification to admin with DB credentials emailService.sendNewClientAdmin({ clienteNombre: data.nombre, clienteRfc: data.rfc.toUpperCase(), adminEmail: data.adminEmail, adminNombre: data.adminNombre, tempPassword, databaseName, plan, }).catch(err => console.error('[EMAIL] New client admin email failed:', err)); return { tenant, user, tempPassword }; } /** * Flow "Agregar empresa" — un user existente (típicamente owner) agrega un * segundo RFC bajo su cuenta. A diferencia de `createTenant()`, NO crea un user * nuevo: el caller se vuelve owner de la empresa nueva vía TenantMembership. * * Sin trial por default — el check de trial por owner (fase 5) bloquearía * cualquier intento de re-activarlo. El owner contrata el plan desde la página * de suscripción del nuevo tenant tras esta llamada. */ export async function addTenantToOwner(data: { userId: string; nombre: string; rfc: string; plan?: DespachoPlan; }) { const plan = data.plan || 'trial'; const rfcUpper = data.rfc.toUpperCase(); // Valida que el RFC no exista en el sistema const existingTenant = await prisma.tenant.findUnique({ where: { rfc: rfcUpper } }); if (existingTenant) { throw new Error('Ya existe una empresa con ese RFC en el sistema'); } // Valida que el user exista y esté activo const user = await prisma.user.findUnique({ where: { id: data.userId } }); if (!user || !user.active) throw new Error('Usuario no encontrado'); // 1. Provision BD dedicada const databaseName = await tenantDb.provisionDatabase(rfcUpper); // 1b. Register tenant database in Metabase (non-blocking, logs errors only) metabaseService.registerDatabase({ nombre: data.nombre, dbName: databaseName, }).catch(err => console.error('[METABASE] Register failed:', err)); // 2. Crea el tenant const tenant = await prisma.tenant.create({ data: { nombre: data.nombre, rfc: rfcUpper, plan, databaseName, }, }); // 3. Crea membership del caller como owner const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } }); if (!ownerRol) throw new Error('Rol owner no encontrado'); await prisma.tenantMembership.create({ data: { userId: user.id, tenantId: tenant.id, rolId: ownerRol.id, isOwner: true, active: true, }, }); // 4. Subscripción pending (se activa al contratar un plan) await prisma.subscription.create({ data: { tenantId: tenant.id, plan, status: 'pending', amount: 0, // el precio real se setea al contratar frequency: 'monthly', }, }); return { tenant }; } /** * Lista detallada de tenants del user actual con estado de suscripción. Usado * por la página `/mis-empresas` — incluye plan, status, currentPeriodEnd, * pendingPlan si aplica. * * @param onlyOwner filtra a solo memberships donde isOwner=true. Default true: * un user contador que trabaja en empresa ajena NO la ve aquí, pero sí sus * propias empresas donde es owner. El header switcher usa un endpoint distinto. */ export async function getMyTenantsDetailed(userId: string, onlyOwner = true) { const memberships = await prisma.tenantMembership.findMany({ where: { userId, active: true, ...(onlyOwner ? { isOwner: true } : {}) }, include: { tenant: { include: { subscriptions: { orderBy: { createdAt: 'desc' }, take: 1, }, }, }, rol: { select: { nombre: true } }, }, orderBy: { joinedAt: 'asc' }, }); return memberships .filter(m => m.tenant.active) .map(m => { const sub = m.tenant.subscriptions[0] || null; return { tenantId: m.tenant.id, nombre: m.tenant.nombre, rfc: m.tenant.rfc, plan: m.tenant.plan, role: m.rol.nombre, isOwner: m.isOwner, trialEndsAt: m.tenant.trialEndsAt, subscription: sub ? { status: sub.status, plan: sub.plan, amount: sub.amount ? Number(sub.amount) : 0, frequency: sub.frequency, currentPeriodEnd: sub.currentPeriodEnd, pendingPlan: sub.pendingPlan, pendingEffectiveAt: sub.pendingEffectiveAt, } : null, }; }); } export async function updateTenant(id: string, data: { nombre?: string; rfc?: string; plan?: DespachoPlan; active?: boolean; amount?: number; firstPaymentDueAt?: string | null; }) { const tenant = await prisma.tenant.update({ where: { id }, data: { ...(data.nombre && { nombre: data.nombre }), ...(data.rfc && { rfc: data.rfc.toUpperCase() }), ...(data.plan && { plan: data.plan }), ...(data.active !== undefined && { active: data.active }), }, select: { id: true, nombre: true, rfc: true, plan: true, databaseName: true, active: true, createdAt: true, } }); // Actualizar subscription del tenant (plan custom o cualquier plan con amount) if (data.amount !== undefined || data.firstPaymentDueAt !== undefined) { const subscription = await prisma.subscription.findFirst({ where: { tenantId: id }, orderBy: { createdAt: 'desc' }, }); if (subscription) { const updateData: any = {}; if (data.amount !== undefined) { updateData.amount = data.amount; } if (data.firstPaymentDueAt !== undefined) { updateData.currentPeriodEnd = data.firstPaymentDueAt ? new Date(data.firstPaymentDueAt) : null; } await prisma.subscription.update({ where: { id: subscription.id }, data: updateData, }); } } return tenant; } export async function getDatosFiscales(id: string) { return prisma.tenant.findUnique({ where: { id }, select: { codigoPostal: true, calle: true, numExterior: true, numInterior: true, colonia: true, ciudad: true, municipio: true, estado: true, telefono: true, }, }); } export async function updateDatosFiscales(id: string, data: { codigoPostal?: string; calle?: string; numExterior?: string; numInterior?: string; colonia?: string; ciudad?: string; municipio?: string; estado?: string; telefono?: string; }) { return prisma.tenant.update({ where: { id }, data, select: { codigoPostal: true, calle: true, numExterior: true, numInterior: true, colonia: true, ciudad: true, municipio: true, estado: true, telefono: true, }, }); } /** * Preferencias de auto-facturación de pagos de suscripción. * Lee también los regímenes activos del tenant — útil para que la UI muestre * las opciones del dropdown "Régimen preferido" sin queries adicionales. */ export async function getPreferenciasFacturacion(id: string) { const tenant = await prisma.tenant.findUnique({ where: { id }, select: { factPreferencia: true, factUsoCfdi: true, factRegimenPreferido: true, regimenesActivos: { select: { regimen: { select: { clave: true, descripcion: true } } }, orderBy: { createdAt: 'asc' }, }, }, }); if (!tenant) return null; return { factPreferencia: tenant.factPreferencia, factUsoCfdi: tenant.factUsoCfdi, factRegimenPreferido: tenant.factRegimenPreferido, regimenesActivos: tenant.regimenesActivos.map(ra => ra.regimen), }; } export async function updatePreferenciasFacturacion(id: string, data: { factPreferencia?: 'publico_general' | 'mis_datos'; factUsoCfdi?: string; factRegimenPreferido?: string | null; }) { return prisma.tenant.update({ where: { id }, data, select: { factPreferencia: true, factUsoCfdi: true, factRegimenPreferido: true, }, }); } export async function deleteTenant(id: string) { const tenant = await prisma.tenant.findUnique({ where: { id }, select: { databaseName: true }, }); // Soft-delete the tenant record await prisma.tenant.update({ where: { id }, data: { active: false } }); // Soft-delete the database (rename with _deleted_ suffix) if (tenant) { await tenantDb.deprovisionDatabase(tenant.databaseName); tenantDb.invalidatePool(id); // Remove from Metabase (non-blocking) metabaseService.deleteDatabase(tenant.databaseName).catch(err => console.error('[METABASE] Delete failed:', err) ); } }