Files
HoruxDespachosNuevo/apps/api/src/services/tenants.service.ts
Horux Dev 9f11a0ba39 feat: facturación primer pago, fixes SAT/MP, autocompletado RFCs/conceptos
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
2026-05-09 21:56:42 +00:00

435 lines
12 KiB
TypeScript

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