Initial commit - Horux Despachos NL
This commit is contained in:
399
apps/api/src/services/tenants.service.ts
Normal file
399
apps/api/src/services/tenants.service.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user