Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,399 @@
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,
_count: {
select: { memberships: { where: { active: true } } as any }
}
},
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,
}
});
}
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;
}) {
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 tenant = await prisma.tenant.create({
data: {
nombre: data.nombre,
rfc: data.rfc.toUpperCase(),
plan,
databaseName,
}
});
// 3. Create admin user with temp password
const tempPassword = randomBytes(4).toString('hex'); // 8-char random
const hashedPassword = await bcrypt.hash(tempPassword, 10);
// Get owner role ID from roles table (rol que asignamos al dueño del tenant al crearlo)
const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
if (!ownerRol) throw new Error('Rol owner no encontrado en la base de datos');
const user = await prisma.user.create({
data: {
email: data.adminEmail,
passwordHash: hashedPassword,
nombre: data.adminNombre,
lastTenantId: tenant.id,
},
});
// Crea membership owner del nuevo user en su tenant (fase 4 multi-tenant)
await prisma.tenantMembership.create({
data: {
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;
}) {
return 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,
}
});
}
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)
);
}
}