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
435 lines
12 KiB
TypeScript
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)
|
|
);
|
|
}
|
|
}
|