Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 additions and 0 deletions

7
apps/api/.env.example Normal file
View File

@@ -0,0 +1,7 @@
NODE_ENV=development
PORT=4000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/horux360?schema=public"
JWT_SECRET=your-super-secret-jwt-key-min-32-chars-long-for-development
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CORS_ORIGIN=http://localhost:3000

65
apps/api/package.json Normal file
View File

@@ -0,0 +1,65 @@
{
"name": "@horux/api",
"version": "0.0.1",
"private": true,
"author": "Carlos e Ivan (Horux 360)",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"import:lista-negra": "tsx scripts/import-lista-negra.ts",
"db:migrate-tenants": "tsx scripts/migrate-tenants.ts",
"bootstrap:admin-global": "tsx scripts/bootstrap-horux360-admin.ts",
"legal:sync": "node scripts/extract-terminos.mjs",
"email:preview": "tsx scripts/preview-emails.mjs"
},
"dependencies": {
"@horux/core": "workspace:*",
"@horux/shared": "workspace:*",
"@nodecfdi/cfdi-core": "^1.0.1",
"@nodecfdi/credentials": "^3.2.0",
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
"@prisma/client": "^5.22.0",
"adm-zip": "^0.5.16",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"facturapi": "^4.14.2",
"fast-xml-parser": "^5.3.3",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mercadopago": "^2.12.0",
"node-cron": "^4.2.1",
"node-forge": "^1.3.3",
"nodemailer": "^8.0.2",
"pdf-parse": "^2.4.5",
"pg": "^8.18.0",
"playwright": "^1.59.1",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^7.0.11",
"@types/pg": "^8.18.0",
"express-rate-limit": "^8.3.1",
"prisma": "^5.22.0",
"sql.js": "^1.14.1",
"tsx": "^4.19.0",
"typescript": "^5.3.0"
}
}

View File

@@ -0,0 +1,121 @@
// Catálogos SAT CFDI 4.0 para facturación
export const FORMAS_PAGO = [
{ clave: '01', descripcion: 'Efectivo' },
{ clave: '02', descripcion: 'Cheque nominativo' },
{ clave: '03', descripcion: 'Transferencia electrónica de fondos' },
{ clave: '04', descripcion: 'Tarjeta de crédito' },
{ clave: '05', descripcion: 'Monedero electrónico' },
{ clave: '06', descripcion: 'Dinero electrónico' },
{ clave: '08', descripcion: 'Vales de despensa' },
{ clave: '12', descripcion: 'Dación en pago' },
{ clave: '13', descripcion: 'Pago por subrogación' },
{ clave: '14', descripcion: 'Pago por consignación' },
{ clave: '15', descripcion: 'Condonación' },
{ clave: '17', descripcion: 'Compensación' },
{ clave: '23', descripcion: 'Novación' },
{ clave: '24', descripcion: 'Confusión' },
{ clave: '25', descripcion: 'Remisión de deuda' },
{ clave: '26', descripcion: 'Prescripción o caducidad' },
{ clave: '27', descripcion: 'A satisfacción del acreedor' },
{ clave: '28', descripcion: 'Tarjeta de débito' },
{ clave: '29', descripcion: 'Tarjeta de servicios' },
{ clave: '30', descripcion: 'Aplicación de anticipos' },
{ clave: '31', descripcion: 'Intermediario pagos' },
{ clave: '99', descripcion: 'Por definir' },
];
export const METODOS_PAGO = [
{ clave: 'PUE', descripcion: 'Pago en una sola exhibición' },
{ clave: 'PPD', descripcion: 'Pago en parcialidades o diferido' },
];
export const USOS_CFDI = [
{ clave: 'G01', descripcion: 'Adquisición de mercancías', personaFisica: true, personaMoral: true },
{ clave: 'G02', descripcion: 'Devoluciones, descuentos o bonificaciones', personaFisica: true, personaMoral: true },
{ clave: 'G03', descripcion: 'Gastos en general', personaFisica: true, personaMoral: true },
{ clave: 'I01', descripcion: 'Construcciones', personaFisica: true, personaMoral: true },
{ clave: 'I02', descripcion: 'Mobiliario y equipo de oficina por inversiones', personaFisica: true, personaMoral: true },
{ clave: 'I03', descripcion: 'Equipo de transporte', personaFisica: true, personaMoral: true },
{ clave: 'I04', descripcion: 'Equipo de cómputo y accesorios', personaFisica: true, personaMoral: true },
{ clave: 'I05', descripcion: 'Dados, troqueles, moldes, matrices y herramental', personaFisica: true, personaMoral: true },
{ clave: 'I06', descripcion: 'Comunicaciones telefónicas', personaFisica: true, personaMoral: true },
{ clave: 'I07', descripcion: 'Comunicaciones satelitales', personaFisica: true, personaMoral: true },
{ clave: 'I08', descripcion: 'Otra maquinaria y equipo', personaFisica: true, personaMoral: true },
{ clave: 'D01', descripcion: 'Honorarios médicos, dentales y gastos hospitalarios', personaFisica: true, personaMoral: false },
{ clave: 'D02', descripcion: 'Gastos médicos por incapacidad o discapacidad', personaFisica: true, personaMoral: false },
{ clave: 'D03', descripcion: 'Gastos funerales', personaFisica: true, personaMoral: false },
{ clave: 'D04', descripcion: 'Donativos', personaFisica: true, personaMoral: true },
{ clave: 'D05', descripcion: 'Intereses reales efectivamente pagados por créditos hipotecarios', personaFisica: true, personaMoral: false },
{ clave: 'D06', descripcion: 'Aportaciones voluntarias al SAR', personaFisica: true, personaMoral: false },
{ clave: 'D07', descripcion: 'Primas por seguros de gastos médicos', personaFisica: true, personaMoral: false },
{ clave: 'D08', descripcion: 'Gastos de transportación escolar obligatoria', personaFisica: true, personaMoral: false },
{ clave: 'D09', descripcion: 'Depósitos en cuentas para el ahorro, primas de pensiones', personaFisica: true, personaMoral: false },
{ clave: 'D10', descripcion: 'Pagos por servicios educativos (colegiaturas)', personaFisica: true, personaMoral: false },
{ clave: 'S01', descripcion: 'Sin efectos fiscales', personaFisica: true, personaMoral: true },
{ clave: 'CP01', descripcion: 'Pagos', personaFisica: true, personaMoral: true },
{ clave: 'CN01', descripcion: 'Nómina', personaFisica: true, personaMoral: false },
];
export const MONEDAS = [
{ clave: 'MXN', descripcion: 'Peso Mexicano', decimales: 2 },
{ clave: 'USD', descripcion: 'Dólar Americano', decimales: 2 },
{ clave: 'EUR', descripcion: 'Euro', decimales: 2 },
{ clave: 'GBP', descripcion: 'Libra Esterlina', decimales: 2 },
{ clave: 'CAD', descripcion: 'Dólar Canadiense', decimales: 2 },
{ clave: 'JPY', descripcion: 'Yen Japonés', decimales: 0 },
{ clave: 'XXX', descripcion: 'Los códigos asignados para transacciones en que intervenga ninguna moneda', decimales: 0 },
];
export const CLAVES_UNIDAD = [
{ clave: 'H87', descripcion: 'Pieza' },
{ clave: 'E48', descripcion: 'Unidad de servicio' },
{ clave: 'KGM', descripcion: 'Kilogramo' },
{ clave: 'LTR', descripcion: 'Litro' },
{ clave: 'MTR', descripcion: 'Metro' },
{ clave: 'MTK', descripcion: 'Metro cuadrado' },
{ clave: 'MTQ', descripcion: 'Metro cúbico' },
{ clave: 'KWH', descripcion: 'Kilovatio hora' },
{ clave: 'TNE', descripcion: 'Tonelada' },
{ clave: 'GRM', descripcion: 'Gramo' },
{ clave: 'HUR', descripcion: 'Hora' },
{ clave: 'DAY', descripcion: 'Día' },
{ clave: 'MON', descripcion: 'Mes' },
{ clave: 'ANN', descripcion: 'Año' },
{ clave: 'XBX', descripcion: 'Caja' },
{ clave: 'XPK', descripcion: 'Paquete' },
{ clave: 'XKI', descripcion: 'Kit' },
{ clave: 'SET', descripcion: 'Conjunto' },
{ clave: 'XLT', descripcion: 'Lote' },
{ clave: 'ACT', descripcion: 'Actividad' },
{ clave: 'XUN', descripcion: 'Unidad' },
{ clave: 'DPC', descripcion: 'Docena de piezas' },
{ clave: 'XRO', descripcion: 'Rollo' },
{ clave: 'GLL', descripcion: 'Galón' },
{ clave: 'MLT', descripcion: 'Mililitro' },
{ clave: 'CMT', descripcion: 'Centímetro' },
];
export const OBJETOS_IMP = [
{ clave: '01', descripcion: 'No objeto de impuesto' },
{ clave: '02', descripcion: 'Sí objeto de impuesto' },
{ clave: '03', descripcion: 'Sí objeto del impuesto y no obligado al desglose' },
{ clave: '04', descripcion: 'Sí objeto del impuesto y no causa impuesto' },
];
export const TIPOS_RELACION = [
{ clave: '01', descripcion: 'Nota de crédito de los documentos relacionados' },
{ clave: '02', descripcion: 'Nota de débito de los documentos relacionados' },
{ clave: '03', descripcion: 'Devolución de mercancía sobre facturas o traslados previos' },
{ clave: '04', descripcion: 'Sustitución de los CFDI previos' },
{ clave: '05', descripcion: 'Traslados de mercancías facturados previamente' },
{ clave: '06', descripcion: 'Factura generada por los traslados previos' },
{ clave: '07', descripcion: 'CFDI por aplicación de anticipo' },
];
export const EXPORTACIONES = [
{ clave: '01', descripcion: 'No aplica' },
{ clave: '02', descripcion: 'Definitiva' },
{ clave: '03', descripcion: 'Temporal' },
{ clave: '04', descripcion: 'Definitiva con clave distinta a A1 o cuando no existe enajenación en términos del CFF' },
];

View File

@@ -0,0 +1,185 @@
// Catálogo de eventos fiscales
export const EVENTOS_FISCALES = [
{
titulo: 'Declaración mensual ISR',
tipo: 'declaracion',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'Declaración mensual IVA',
tipo: 'declaracion',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'Declaración mensual IEPS',
tipo: 'declaracion',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'Declaración de sueldos y salarios',
tipo: 'declaracion',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: 'todos',
condicion: 'tiene_nomina',
},
{
titulo: 'Pago provisional ISR',
tipo: 'pago',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: true,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'Pago provisional IVA',
tipo: 'pago',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: true,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'Pago provisional IEPS',
tipo: 'pago',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: true,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'DIOT',
tipo: 'obligacion',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: '601,603,607,608,610,611,612,614,615,620,622,623,624',
condicion: null, // 612 aplica condición ingresos_4m, se valida en runtime
},
{
titulo: 'Contabilidad electrónica',
tipo: 'obligacion',
diaBase: 3,
mesRelativo: 2,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: '601,603,607,608,610,611,612,614,615,620,622,623,624',
condicion: null,
},
{
titulo: 'Declaración anual PM',
tipo: 'declaracion',
diaBase: 31,
mesRelativo: 0,
mesFijo: 3,
recurrencia: 'anual',
usaExtensionRfc: false,
regimenes: '601,603,620,622,623,624',
condicion: null,
},
{
titulo: 'Declaración anual PF',
tipo: 'declaracion',
diaBase: 30,
mesRelativo: 0,
mesFijo: 4,
recurrencia: 'anual',
usaExtensionRfc: false,
regimenes: '605,606,607,608,611,612,614,615,621,625,626',
condicion: null,
},
{
titulo: 'Informativa Sueldos y Salarios',
tipo: 'informativa',
diaBase: 15,
mesRelativo: 0,
mesFijo: 2,
recurrencia: 'anual',
usaExtensionRfc: false,
regimenes: 'todos',
condicion: 'tiene_nomina',
},
];
// Días festivos oficiales de México (2020-2027)
// Incluye: 1 ene, 5 feb, 21 mar, 1 may, 16 sep, 1 oct (cambio poder), 20 nov, 25 dic
// + cambios de poder cada 6 años, semana santa variable
export const DIAS_INHABILES: { fecha: string; nombre: string }[] = [];
function addFestivos(año: number) {
const fijos = [
{ mes: 1, dia: 1, nombre: 'Año Nuevo' },
{ mes: 5, dia: 1, nombre: 'Día del Trabajo' },
{ mes: 9, dia: 16, nombre: 'Independencia de México' },
{ mes: 12, dia: 25, nombre: 'Navidad' },
];
// Primer lunes de febrero (Constitución)
const feb1 = new Date(año, 1, 1);
const primerLunesFeb = new Date(año, 1, 1 + ((8 - feb1.getDay()) % 7));
DIAS_INHABILES.push({
fecha: primerLunesFeb.toISOString().split('T')[0],
nombre: 'Día de la Constitución',
});
// Tercer lunes de marzo (Benito Juárez)
const mar1 = new Date(año, 2, 1);
const primerLunesMar = new Date(año, 2, 1 + ((8 - mar1.getDay()) % 7));
const tercerLunesMar = new Date(primerLunesMar);
tercerLunesMar.setDate(tercerLunesMar.getDate() + 14);
DIAS_INHABILES.push({
fecha: tercerLunesMar.toISOString().split('T')[0],
nombre: 'Natalicio de Benito Juárez',
});
// Tercer lunes de noviembre (Revolución)
const nov1 = new Date(año, 10, 1);
const primerLunesNov = new Date(año, 10, 1 + ((8 - nov1.getDay()) % 7));
const tercerLunesNov = new Date(primerLunesNov);
tercerLunesNov.setDate(tercerLunesNov.getDate() + 14);
DIAS_INHABILES.push({
fecha: tercerLunesNov.toISOString().split('T')[0],
nombre: 'Día de la Revolución',
});
for (const f of fijos) {
DIAS_INHABILES.push({
fecha: `${año}-${String(f.mes).padStart(2, '0')}-${String(f.dia).padStart(2, '0')}`,
nombre: f.nombre,
});
}
// Cambio de poder (1 oct cada 6 años: 2024, 2030...)
if (año % 6 === 0 || (año - 2024) % 6 === 0) {
DIAS_INHABILES.push({
fecha: `${año}-10-01`,
nombre: 'Transmisión del Poder Ejecutivo Federal',
});
}
}
for (let y = 2020; y <= 2027; y++) addFestivos(y);

103
apps/api/prisma/isr-data.ts Normal file
View File

@@ -0,0 +1,103 @@
// Tasas RESICO (Art. 113-E) - iguales 2022-2026
export const RESICO_TASAS = [
{ montoMaximo: 25000.00, porcentaje: 1.00 },
{ montoMaximo: 50000.00, porcentaje: 1.10 },
{ montoMaximo: 83888.33, porcentaje: 1.50 },
{ montoMaximo: 208333.33, porcentaje: 2.00 },
{ montoMaximo: 291666.66, porcentaje: 2.50 },
];
// Tarifas ISR mensuales (Art. 96) por año
export const ISR_TARIFAS: Record<number, { li: number; ls: number | null; cf: number; pe: number }[]> = {
2020: [
{ li: 0.01, ls: 578.52, cf: 0, pe: 1.92 },
{ li: 578.53, ls: 4910.18, cf: 11.11, pe: 6.40 },
{ li: 4910.19, ls: 8629.20, cf: 288.33, pe: 10.88 },
{ li: 8629.21, ls: 10031.07, cf: 692.96, pe: 16.00 },
{ li: 10031.08, ls: 12009.94, cf: 917.26, pe: 17.92 },
{ li: 12009.95, ls: 24222.31, cf: 1271.87, pe: 21.36 },
{ li: 24222.32, ls: 38177.69, cf: 3880.44, pe: 23.52 },
{ li: 38177.70, ls: 72887.50, cf: 7162.74, pe: 30.00 },
{ li: 72887.51, ls: 97183.33, cf: 17575.69, pe: 32.00 },
{ li: 97183.34, ls: 291550.00, cf: 25350.35, pe: 34.00 },
{ li: 291550.01, ls: null, cf: 91435.02, pe: 35.00 },
],
2021: [
{ li: 0.01, ls: 644.58, cf: 0, pe: 1.92 },
{ li: 644.59, ls: 5470.92, cf: 12.38, pe: 6.40 },
{ li: 5470.93, ls: 9614.66, cf: 321.26, pe: 10.88 },
{ li: 9614.67, ls: 11176.62, cf: 772.10, pe: 16.00 },
{ li: 11176.63, ls: 13381.47, cf: 1022.01, pe: 17.92 },
{ li: 13381.48, ls: 26988.50, cf: 1417.12, pe: 21.36 },
{ li: 26988.51, ls: 42537.58, cf: 4323.58, pe: 23.52 },
{ li: 42537.59, ls: 81211.25, cf: 7980.73, pe: 30.00 },
{ li: 81211.26, ls: 108281.67, cf: 19582.83, pe: 32.00 },
{ li: 108281.68, ls: 324845.01, cf: 28245.36, pe: 34.00 },
{ li: 324845.02, ls: null, cf: 101876.90, pe: 35.00 },
],
2022: [
{ li: 0.01, ls: 644.58, cf: 0, pe: 1.92 },
{ li: 644.59, ls: 5470.92, cf: 12.38, pe: 6.40 },
{ li: 5470.93, ls: 9614.66, cf: 321.26, pe: 10.88 },
{ li: 9614.67, ls: 11176.62, cf: 772.10, pe: 16.00 },
{ li: 11176.63, ls: 13381.47, cf: 1022.01, pe: 17.92 },
{ li: 13381.48, ls: 26988.50, cf: 1417.12, pe: 21.36 },
{ li: 26988.51, ls: 42537.58, cf: 4323.58, pe: 23.52 },
{ li: 42537.59, ls: 81211.25, cf: 7980.73, pe: 30.00 },
{ li: 81211.26, ls: 108281.67, cf: 19582.83, pe: 32.00 },
{ li: 108281.68, ls: 324845.01, cf: 28245.36, pe: 34.00 },
{ li: 324845.02, ls: null, cf: 101876.90, pe: 35.00 },
],
2023: [
{ li: 0.01, ls: 746.04, cf: 0, pe: 1.92 },
{ li: 746.05, ls: 6332.05, cf: 14.32, pe: 6.40 },
{ li: 6332.06, ls: 11128.01, cf: 371.83, pe: 10.88 },
{ li: 11128.02, ls: 12935.82, cf: 893.63, pe: 16.00 },
{ li: 12935.83, ls: 15487.71, cf: 1182.88, pe: 17.92 },
{ li: 15487.72, ls: 31236.49, cf: 1640.18, pe: 21.36 },
{ li: 31236.50, ls: 49233.00, cf: 5004.12, pe: 23.52 },
{ li: 49233.01, ls: 93993.90, cf: 9236.89, pe: 30.00 },
{ li: 93993.91, ls: 125325.20, cf: 22665.17, pe: 32.00 },
{ li: 125325.21, ls: 375975.61, cf: 32691.18, pe: 34.00 },
{ li: 375975.62, ls: null, cf: 117912.32, pe: 35.00 },
],
2024: [
{ li: 0.01, ls: 746.04, cf: 0, pe: 1.92 },
{ li: 746.05, ls: 6332.05, cf: 14.32, pe: 6.40 },
{ li: 6332.06, ls: 11128.01, cf: 371.83, pe: 10.88 },
{ li: 11128.02, ls: 12935.82, cf: 893.63, pe: 16.00 },
{ li: 12935.83, ls: 15487.71, cf: 1182.88, pe: 17.92 },
{ li: 15487.72, ls: 31236.49, cf: 1640.18, pe: 21.36 },
{ li: 31236.50, ls: 49233.00, cf: 5004.12, pe: 23.52 },
{ li: 49233.01, ls: 93993.90, cf: 9236.89, pe: 30.00 },
{ li: 93993.91, ls: 125325.20, cf: 22665.17, pe: 32.00 },
{ li: 125325.21, ls: 375975.61, cf: 32691.18, pe: 34.00 },
{ li: 375975.62, ls: null, cf: 117912.32, pe: 35.00 },
],
2025: [
{ li: 0.01, ls: 746.04, cf: 0, pe: 1.92 },
{ li: 746.05, ls: 6332.05, cf: 14.32, pe: 6.40 },
{ li: 6332.06, ls: 11128.01, cf: 371.83, pe: 10.88 },
{ li: 11128.02, ls: 12935.82, cf: 893.63, pe: 16.00 },
{ li: 12935.83, ls: 15487.71, cf: 1182.88, pe: 17.92 },
{ li: 15487.72, ls: 31236.49, cf: 1640.18, pe: 21.36 },
{ li: 31236.50, ls: 49233.00, cf: 5004.12, pe: 23.52 },
{ li: 49233.01, ls: 93993.90, cf: 9236.89, pe: 30.00 },
{ li: 93993.91, ls: 125325.20, cf: 22665.17, pe: 32.00 },
{ li: 125325.21, ls: 375975.61, cf: 32691.18, pe: 34.00 },
{ li: 375975.62, ls: null, cf: 117912.32, pe: 35.00 },
],
2026: [
{ li: 0.01, ls: 844.59, cf: 0, pe: 1.92 },
{ li: 844.60, ls: 7168.51, cf: 16.22, pe: 6.40 },
{ li: 7168.52, ls: 12598.02, cf: 420.95, pe: 10.88 },
{ li: 12598.03, ls: 14644.64, cf: 1011.68, pe: 16.00 },
{ li: 14644.65, ls: 17533.64, cf: 1339.14, pe: 17.92 },
{ li: 17533.65, ls: 35362.83, cf: 1856.84, pe: 21.36 },
{ li: 35362.84, ls: 55736.68, cf: 5665.16, pe: 23.52 },
{ li: 55736.69, ls: 106410.50, cf: 10457.09, pe: 30.00 },
{ li: 106410.51, ls: 141880.66, cf: 25659.23, pe: 32.00 },
{ li: 141880.67, ls: 425641.99, cf: 37009.69, pe: 34.00 },
{ li: 425642.00, ls: null, cf: 133488.54, pe: 35.00 },
],
};

View File

@@ -0,0 +1,634 @@
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('starter', 'business', 'business_ia', 'custom', 'enterprise');
-- CreateEnum
CREATE TYPE "PlatformRole" AS ENUM ('platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance');
-- CreateEnum
CREATE TYPE "SatSyncType" AS ENUM ('initial', 'daily', 'incremental');
-- CreateEnum
CREATE TYPE "SatSyncStatus" AS ENUM ('pending', 'running', 'completed', 'failed');
-- CreateEnum
CREATE TYPE "CfdiSyncType" AS ENUM ('emitidos', 'recibidos');
-- CreateTable
CREATE TABLE "tenants" (
"id" TEXT NOT NULL,
"nombre" TEXT NOT NULL,
"rfc" TEXT NOT NULL,
"plan" "Plan" NOT NULL DEFAULT 'starter',
"database_name" TEXT NOT NULL,
"cfdi_limit" INTEGER NOT NULL DEFAULT 100,
"users_limit" INTEGER NOT NULL DEFAULT 1,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3),
"trial_ends_at" TIMESTAMP(3),
"facturapi_org_id" TEXT,
"codigo_postal" VARCHAR(5),
"calle" VARCHAR(255),
"num_exterior" VARCHAR(20),
"num_interior" VARCHAR(20),
"colonia" VARCHAR(255),
"ciudad" VARCHAR(100),
"municipio" VARCHAR(100),
"estado" VARCHAR(100),
"telefono" VARCHAR(20),
CONSTRAINT "tenants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"nombre" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"last_login" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"token_version" INTEGER NOT NULL DEFAULT 0,
"last_tenant_id" TEXT,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_memberships" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"rol_id" INTEGER NOT NULL,
"is_owner" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenant_memberships_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" SERIAL NOT NULL,
"nombre" VARCHAR(20) NOT NULL,
"descripcion" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "refresh_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "password_reset_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "regimenes" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(3) NOT NULL,
"descripcion" TEXT NOT NULL,
"tipo_persona" VARCHAR(20) NOT NULL,
"activo" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "regimenes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_regimenes_ignorados" (
"id" SERIAL NOT NULL,
"tenant_id" TEXT NOT NULL,
"regimen_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenant_regimenes_ignorados_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_regimenes_activos" (
"id" SERIAL NOT NULL,
"tenant_id" TEXT NOT NULL,
"regimen_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenant_regimenes_activos_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "eventos_fiscales_catalogo" (
"id" SERIAL NOT NULL,
"titulo" TEXT NOT NULL,
"descripcion" TEXT,
"tipo" VARCHAR(20) NOT NULL,
"dia_base" INTEGER NOT NULL,
"mes_relativo" INTEGER NOT NULL DEFAULT 1,
"mes_fijo" INTEGER,
"recurrencia" VARCHAR(20) NOT NULL DEFAULT 'mensual',
"usa_extension_rfc" BOOLEAN NOT NULL DEFAULT false,
"regimenes" TEXT NOT NULL DEFAULT 'todos',
"condicion" VARCHAR(50),
"activo" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "eventos_fiscales_catalogo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista_negra" (
"id" SERIAL NOT NULL,
"rfc" VARCHAR(13) NOT NULL,
"nombre" TEXT NOT NULL,
"situacion" VARCHAR(30) NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lista_negra_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dias_inhabiles" (
"id" SERIAL NOT NULL,
"fecha" DATE NOT NULL,
"nombre" TEXT NOT NULL,
CONSTRAINT "dias_inhabiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "isr_resico_tasas" (
"id" SERIAL NOT NULL,
"anio" INTEGER NOT NULL,
"monto_maximo" DECIMAL(18,2) NOT NULL,
"porcentaje" DECIMAL(5,2) NOT NULL,
CONSTRAINT "isr_resico_tasas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "isr_tarifas" (
"id" SERIAL NOT NULL,
"anio" INTEGER NOT NULL,
"limite_inferior" DECIMAL(18,2) NOT NULL,
"limite_superior" DECIMAL(18,2),
"cuota_fija" DECIMAL(18,2) NOT NULL,
"porcentaje_excedente" DECIMAL(5,2) NOT NULL,
CONSTRAINT "isr_tarifas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "coeficiente_utilidad" (
"id" SERIAL NOT NULL,
"tenant_id" TEXT NOT NULL,
"anio" INTEGER NOT NULL,
"coeficiente" DECIMAL(10,4) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "coeficiente_utilidad_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "fiel_credentials" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"rfc" VARCHAR(13) NOT NULL,
"cer_data" BYTEA NOT NULL,
"key_data" BYTEA NOT NULL,
"key_password_encrypted" BYTEA NOT NULL,
"cer_iv" BYTEA NOT NULL,
"cer_tag" BYTEA NOT NULL,
"key_iv" BYTEA NOT NULL,
"key_tag" BYTEA NOT NULL,
"password_iv" BYTEA NOT NULL,
"password_tag" BYTEA NOT NULL,
"serial_number" VARCHAR(50),
"valid_from" TIMESTAMP(3) NOT NULL,
"valid_until" TIMESTAMP(3) NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fiel_credentials_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscriptions" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"plan" "Plan" NOT NULL,
"mp_preapproval_id" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"amount" DECIMAL(10,2) NOT NULL,
"frequency" TEXT NOT NULL DEFAULT 'monthly',
"current_period_start" TIMESTAMP(3),
"current_period_end" TIMESTAMP(3),
"pending_plan" "Plan",
"pending_frequency" TEXT,
"pending_effective_at" TIMESTAMP(3),
"upgrade_preference_id" TEXT,
"upgrade_target_plan" "Plan",
"upgrade_target_amount" DECIMAL(10,2),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_platform_roles" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"role" "PlatformRole" NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" TEXT,
CONSTRAINT "user_platform_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_log" (
"id" TEXT NOT NULL,
"user_id" TEXT,
"tenant_id" TEXT,
"action" VARCHAR(64) NOT NULL,
"entity_type" VARCHAR(32),
"entity_id" TEXT,
"metadata" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "trial_usages" (
"id" SERIAL NOT NULL,
"rfc" VARCHAR(13) NOT NULL,
"tenant_id" TEXT,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "trial_usages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "plan_prices" (
"id" SERIAL NOT NULL,
"plan" "Plan" NOT NULL,
"frequency" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "plan_prices_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payments" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"subscription_id" TEXT,
"mp_payment_id" TEXT,
"amount" DECIMAL(10,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"payment_method" TEXT,
"paid_at" TIMESTAMP(3),
"facturapi_invoice_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sat_sync_jobs" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"type" "SatSyncType" NOT NULL,
"status" "SatSyncStatus" NOT NULL DEFAULT 'pending',
"date_from" DATE NOT NULL,
"date_to" DATE NOT NULL,
"cfdi_type" "CfdiSyncType",
"sat_request_id" VARCHAR(50),
"sat_package_ids" TEXT[],
"cfdis_found" INTEGER NOT NULL DEFAULT 0,
"cfdis_downloaded" INTEGER NOT NULL DEFAULT 0,
"cfdis_inserted" INTEGER NOT NULL DEFAULT 0,
"cfdis_updated" INTEGER NOT NULL DEFAULT 0,
"progress_percent" INTEGER NOT NULL DEFAULT 0,
"error_message" TEXT,
"started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"retry_count" INTEGER NOT NULL DEFAULT 0,
"next_retry_at" TIMESTAMP(3),
CONSTRAINT "sat_sync_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_forma_pago" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(2) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_forma_pago_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_metodo_pago" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(3) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_metodo_pago_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_uso_cfdi" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(4) NOT NULL,
"descripcion" TEXT NOT NULL,
"persona_fisica" BOOLEAN NOT NULL DEFAULT true,
"persona_moral" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "cat_uso_cfdi_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_moneda" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(3) NOT NULL,
"descripcion" TEXT NOT NULL,
"decimales" INTEGER NOT NULL DEFAULT 2,
CONSTRAINT "cat_moneda_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_clave_unidad" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(10) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_clave_unidad_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_clave_prod_serv" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(8) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_clave_prod_serv_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_objeto_imp" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(2) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_objeto_imp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_tipo_relacion" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(2) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_tipo_relacion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_exportacion" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(2) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_exportacion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "timbre_suscripciones" (
"id" SERIAL NOT NULL,
"tenant_id" TEXT NOT NULL,
"tipo" VARCHAR(10) NOT NULL,
"timbres_limite" INTEGER NOT NULL,
"timbres_usados" INTEGER NOT NULL DEFAULT 0,
"periodo_inicio" DATE NOT NULL,
"periodo_fin" DATE NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "timbre_suscripciones_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tenants_rfc_key" ON "tenants"("rfc");
-- CreateIndex
CREATE UNIQUE INDEX "tenants_database_name_key" ON "tenants"("database_name");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "tenant_memberships_user_id_active_idx" ON "tenant_memberships"("user_id", "active");
-- CreateIndex
CREATE INDEX "tenant_memberships_tenant_id_active_idx" ON "tenant_memberships"("tenant_id", "active");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_memberships_user_id_tenant_id_key" ON "tenant_memberships"("user_id", "tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "roles_nombre_key" ON "roles"("nombre");
-- CreateIndex
CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token");
-- CreateIndex
CREATE UNIQUE INDEX "password_reset_tokens_token_key" ON "password_reset_tokens"("token");
-- CreateIndex
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens"("user_id");
-- CreateIndex
CREATE INDEX "password_reset_tokens_expires_at_idx" ON "password_reset_tokens"("expires_at");
-- CreateIndex
CREATE UNIQUE INDEX "regimenes_clave_key" ON "regimenes"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_regimenes_ignorados_tenant_id_regimen_id_key" ON "tenant_regimenes_ignorados"("tenant_id", "regimen_id");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_regimenes_activos_tenant_id_regimen_id_key" ON "tenant_regimenes_activos"("tenant_id", "regimen_id");
-- CreateIndex
CREATE UNIQUE INDEX "lista_negra_rfc_key" ON "lista_negra"("rfc");
-- CreateIndex
CREATE INDEX "lista_negra_rfc_idx" ON "lista_negra"("rfc");
-- CreateIndex
CREATE UNIQUE INDEX "dias_inhabiles_fecha_key" ON "dias_inhabiles"("fecha");
-- CreateIndex
CREATE UNIQUE INDEX "isr_resico_tasas_anio_monto_maximo_key" ON "isr_resico_tasas"("anio", "monto_maximo");
-- CreateIndex
CREATE UNIQUE INDEX "isr_tarifas_anio_limite_inferior_key" ON "isr_tarifas"("anio", "limite_inferior");
-- CreateIndex
CREATE UNIQUE INDEX "coeficiente_utilidad_tenant_id_anio_key" ON "coeficiente_utilidad"("tenant_id", "anio");
-- CreateIndex
CREATE UNIQUE INDEX "fiel_credentials_tenant_id_key" ON "fiel_credentials"("tenant_id");
-- CreateIndex
CREATE INDEX "subscriptions_tenant_id_idx" ON "subscriptions"("tenant_id");
-- CreateIndex
CREATE INDEX "subscriptions_status_idx" ON "subscriptions"("status");
-- CreateIndex
CREATE INDEX "subscriptions_pending_effective_at_idx" ON "subscriptions"("pending_effective_at");
-- CreateIndex
CREATE INDEX "user_platform_roles_role_idx" ON "user_platform_roles"("role");
-- CreateIndex
CREATE UNIQUE INDEX "user_platform_roles_user_id_role_key" ON "user_platform_roles"("user_id", "role");
-- CreateIndex
CREATE INDEX "audit_log_user_id_created_at_idx" ON "audit_log"("user_id", "created_at");
-- CreateIndex
CREATE INDEX "audit_log_tenant_id_created_at_idx" ON "audit_log"("tenant_id", "created_at");
-- CreateIndex
CREATE INDEX "audit_log_action_created_at_idx" ON "audit_log"("action", "created_at");
-- CreateIndex
CREATE INDEX "audit_log_entity_type_entity_id_idx" ON "audit_log"("entity_type", "entity_id");
-- CreateIndex
CREATE UNIQUE INDEX "trial_usages_rfc_key" ON "trial_usages"("rfc");
-- CreateIndex
CREATE UNIQUE INDEX "plan_prices_plan_frequency_key" ON "plan_prices"("plan", "frequency");
-- CreateIndex
CREATE INDEX "payments_tenant_id_idx" ON "payments"("tenant_id");
-- CreateIndex
CREATE INDEX "payments_subscription_id_idx" ON "payments"("subscription_id");
-- CreateIndex
CREATE INDEX "sat_sync_jobs_tenant_id_idx" ON "sat_sync_jobs"("tenant_id");
-- CreateIndex
CREATE INDEX "sat_sync_jobs_status_idx" ON "sat_sync_jobs"("status");
-- CreateIndex
CREATE INDEX "sat_sync_jobs_status_next_retry_at_idx" ON "sat_sync_jobs"("status", "next_retry_at");
-- CreateIndex
CREATE UNIQUE INDEX "cat_forma_pago_clave_key" ON "cat_forma_pago"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_metodo_pago_clave_key" ON "cat_metodo_pago"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_uso_cfdi_clave_key" ON "cat_uso_cfdi"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_moneda_clave_key" ON "cat_moneda"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_clave_unidad_clave_key" ON "cat_clave_unidad"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_clave_prod_serv_clave_key" ON "cat_clave_prod_serv"("clave");
-- CreateIndex
CREATE INDEX "cat_clave_prod_serv_descripcion_idx" ON "cat_clave_prod_serv"("descripcion");
-- CreateIndex
CREATE UNIQUE INDEX "cat_objeto_imp_clave_key" ON "cat_objeto_imp"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_tipo_relacion_clave_key" ON "cat_tipo_relacion"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_exportacion_clave_key" ON "cat_exportacion"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "timbre_suscripciones_tenant_id_key" ON "timbre_suscripciones"("tenant_id");
-- AddForeignKey
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_rol_id_fkey" FOREIGN KEY ("rol_id") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_regimenes_ignorados" ADD CONSTRAINT "tenant_regimenes_ignorados_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_regimenes_ignorados" ADD CONSTRAINT "tenant_regimenes_ignorados_regimen_id_fkey" FOREIGN KEY ("regimen_id") REFERENCES "regimenes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_regimenes_activos" ADD CONSTRAINT "tenant_regimenes_activos_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_regimenes_activos" ADD CONSTRAINT "tenant_regimenes_activos_regimen_id_fkey" FOREIGN KEY ("regimen_id") REFERENCES "regimenes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "coeficiente_utilidad" ADD CONSTRAINT "coeficiente_utilidad_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fiel_credentials" ADD CONSTRAINT "fiel_credentials_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_platform_roles" ADD CONSTRAINT "user_platform_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sat_sync_jobs" ADD CONSTRAINT "sat_sync_jobs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "timbre_suscripciones" ADD CONSTRAINT "timbre_suscripciones_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,47 @@
-- CreateEnum
CREATE TYPE "PaymentKind" AS ENUM ('subscription', 'timbres_pack');
-- AlterTable
ALTER TABLE "payments" ADD COLUMN "kind" "PaymentKind" NOT NULL DEFAULT 'subscription';
-- CreateTable
CREATE TABLE "timbre_paquetes_catalogo" (
"id" SERIAL NOT NULL,
"cantidad" INTEGER NOT NULL,
"precio" DECIMAL(10,2) NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "timbre_paquetes_catalogo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "timbre_paquetes" (
"id" SERIAL NOT NULL,
"tenant_id" TEXT NOT NULL,
"payment_id" TEXT,
"cantidad" INTEGER NOT NULL,
"usados" INTEGER NOT NULL DEFAULT 0,
"precio" DECIMAL(10,2) NOT NULL,
"adquirido_en" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expira_en" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "timbre_paquetes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "timbre_paquetes_catalogo_cantidad_key" ON "timbre_paquetes_catalogo"("cantidad");
-- CreateIndex
CREATE UNIQUE INDEX "timbre_paquetes_payment_id_key" ON "timbre_paquetes"("payment_id");
-- CreateIndex
CREATE INDEX "timbre_paquetes_tenant_id_expira_en_idx" ON "timbre_paquetes"("tenant_id", "expira_en");
-- AddForeignKey
ALTER TABLE "timbre_paquetes" ADD CONSTRAINT "timbre_paquetes_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "timbre_paquetes" ADD CONSTRAINT "timbre_paquetes_payment_id_fkey" FOREIGN KEY ("payment_id") REFERENCES "payments"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,16 @@
-- CreateEnum
CREATE TYPE "VerticalProfile" AS ENUM ('CONTABLE', 'JURIDICO', 'ARQUITECTURA');
-- CreateEnum
CREATE TYPE "DbMode" AS ENUM ('BYO', 'MANAGED');
-- AlterTable
ALTER TABLE "tenants" ADD COLUMN "connector_last_seen" TIMESTAMP(3),
ADD COLUMN "connector_token_enc" TEXT,
ADD COLUMN "connector_tunnel_hostname" TEXT,
ADD COLUMN "connector_version" VARCHAR(20),
ADD COLUMN "db_connection_enc" TEXT,
ADD COLUMN "db_connection_iv" TEXT,
ADD COLUMN "db_mode" "DbMode",
ADD COLUMN "db_schema_version" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "vertical_profile" "VerticalProfile";

View File

@@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE "plan_catalogo" (
"id" TEXT NOT NULL,
"codename" VARCHAR(50) NOT NULL,
"nombre" TEXT NOT NULL,
"verticalProfile" "VerticalProfile" NOT NULL,
"precio_base" DECIMAL(10,2) NOT NULL,
"frecuencia" VARCHAR(10) NOT NULL,
"limits" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "plan_catalogo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "plan_addon_catalogo" (
"id" TEXT NOT NULL,
"codename" VARCHAR(50) NOT NULL,
"nombre" TEXT NOT NULL,
"verticalProfile" "VerticalProfile",
"precio" DECIMAL(10,2) NOT NULL,
"frecuencia" VARCHAR(10) NOT NULL,
"delta" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "plan_addon_catalogo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "plan_catalogo_codename_key" ON "plan_catalogo"("codename");
-- CreateIndex
CREATE UNIQUE INDEX "plan_addon_catalogo_codename_key" ON "plan_addon_catalogo"("codename");

View File

@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "subscription_addons" (
"id" TEXT NOT NULL,
"subscription_id" TEXT NOT NULL,
"plan_addon_catalogo_id" TEXT NOT NULL,
"mp_preapproval_id" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"quantity" INTEGER NOT NULL DEFAULT 1,
"amount" DECIMAL(10,2) NOT NULL,
"current_period_start" TIMESTAMP(3),
"current_period_end" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscription_addons_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "subscription_addons_subscription_id_idx" ON "subscription_addons"("subscription_id");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_addons_subscription_id_plan_addon_catalogo_id_key" ON "subscription_addons"("subscription_id", "plan_addon_catalogo_id");
-- AddForeignKey
ALTER TABLE "subscription_addons" ADD CONSTRAINT "subscription_addons_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "subscriptions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_addons" ADD CONSTRAINT "subscription_addons_plan_addon_catalogo_id_fkey" FOREIGN KEY ("plan_addon_catalogo_id") REFERENCES "plan_addon_catalogo"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "connector_heartbeats" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"latency_ms" INTEGER NOT NULL,
"version" VARCHAR(20) NOT NULL,
"pg_version" VARCHAR(50),
"status" VARCHAR(20) NOT NULL,
"error_msg" TEXT,
CONSTRAINT "connector_heartbeats_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "connector_heartbeats_tenant_id_timestamp_idx" ON "connector_heartbeats"("tenant_id", "timestamp");
-- AddForeignKey
ALTER TABLE "connector_heartbeats" ADD CONSTRAINT "connector_heartbeats_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "sat_sync_jobs" ADD COLUMN "contribuyente_id" TEXT;

View File

@@ -0,0 +1,10 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "Plan" ADD VALUE 'business_control';
ALTER TYPE "Plan" ADD VALUE 'business_cloud';

View File

@@ -0,0 +1,18 @@
-- Add-ons por contribuyente: permite que SubscriptionAddon se asocie a un
-- contribuyente específico (ej. Lolita IA $250/mes activable por RFC) además
-- de los add-ons a nivel tenant (modulos, +RFCs, +timbres) que tienen
-- contribuyente_id = NULL.
ALTER TABLE "subscription_addons"
ADD COLUMN "contribuyente_id" TEXT;
-- Eliminar el UNIQUE (subscription_id, plan_addon_catalogo_id). Ahora el
-- mismo add-on (p. ej. lolita_ia_contribuyente) puede tener N filas por
-- subscription, una por cada contribuyente que lo contrate.
ALTER TABLE "subscription_addons"
DROP CONSTRAINT IF EXISTS "subscription_addons_subscription_id_plan_addon_catalogo_id_key";
-- Índice por (subscription_id, contribuyente_id) para lookups rápidos
-- "qué add-ons tiene este contribuyente"
CREATE INDEX IF NOT EXISTS "subscription_addons_subscription_id_contribuyente_id_idx"
ON "subscription_addons"("subscription_id", "contribuyente_id");

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Plan" ADD VALUE 'mi_empresa';

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "despacho_plan_prices" (
"plan" TEXT NOT NULL,
"monthly" DECIMAL(10,2),
"first_year" DECIMAL(10,2) NOT NULL,
"renewal" DECIMAL(10,2) NOT NULL,
"permite_monthly" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "despacho_plan_prices_pkey" PRIMARY KEY ("plan")
);
-- Seed inicial con valores actuales del catálogo `DESPACHO_PLAN_PRICES`.
INSERT INTO "despacho_plan_prices" ("plan", "monthly", "first_year", "renewal", "permite_monthly", "updated_at") VALUES
('mi_empresa', 580, 5800, 5800, true, NOW()),
('mi_empresa_plus', 900, 9000, 9000, true, NOW()),
('business_control', NULL, 25850, 25850, false, NOW()),
('business_cloud', NULL, 43000, 43000, false, NOW());

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -0,0 +1,744 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Tenant {
id String @id @default(uuid())
nombre String
rfc String @unique
plan Plan @default(starter)
databaseName String @unique @map("database_name")
cfdiLimit Int @default(100) @map("cfdi_limit")
usersLimit Int @default(1) @map("users_limit")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at")
// Prueba gratuita: si está set y en el futuro, el tenant está en trial.
// Se consume una sola vez por tenant (al activarla, nunca se regenera).
trialEndsAt DateTime? @map("trial_ends_at")
facturapiOrgId String? @map("facturapi_org_id")
// Domicilio fiscal
codigoPostal String? @map("codigo_postal") @db.VarChar(5)
calle String? @db.VarChar(255)
numExterior String? @map("num_exterior") @db.VarChar(20)
numInterior String? @map("num_interior") @db.VarChar(20)
colonia String? @db.VarChar(255)
ciudad String? @db.VarChar(100)
municipio String? @db.VarChar(100)
estado String? @db.VarChar(100)
telefono String? @db.VarChar(20)
// === Despacho fields ===
verticalProfile VerticalProfile? @map("vertical_profile")
dbMode DbMode? @map("db_mode")
dbConnectionEnc String? @map("db_connection_enc")
dbConnectionIv String? @map("db_connection_iv")
dbSchemaVersion Int @default(0) @map("db_schema_version")
connectorTokenEnc String? @map("connector_token_enc")
connectorTunnelHostname String? @map("connector_tunnel_hostname")
connectorLastSeen DateTime? @map("connector_last_seen")
connectorVersion String? @map("connector_version") @db.VarChar(20)
memberships TenantMembership[]
fielCredential FielCredential?
satSyncJobs SatSyncJob[]
subscriptions Subscription[]
payments Payment[]
regimenesIgnorados TenantRegimenIgnorado[]
regimenesActivos TenantRegimenActivo[]
coeficientes CoeficienteUtilidad[]
timbreSuscripcion TimbreSuscripcion?
timbrePaquetes TimbrePaquete[]
connectorHeartbeats ConnectorHeartbeat[]
@@map("tenants")
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String @map("password_hash")
nombre String
active Boolean @default(true)
lastLogin DateTime? @map("last_login")
createdAt DateTime @default(now()) @map("created_at")
// Contador para invalidar sesiones masivamente. Al incrementar, todos los
// JWT emitidos antes (con tokenVersion menor) quedan rechazados en el
// siguiente request. Se incrementa en: password change, password reset,
// logout-all. Default 0 para compat con users pre-rollout.
tokenVersion Int @default(0) @map("token_version")
// Último tenant que el user activó (via switch-tenant). Se usa para resolver
// el "tenant activo al login". Si es null, el login cae al primer membership
// por joinedAt. Se actualiza en cada switch.
lastTenantId String? @map("last_tenant_id")
memberships TenantMembership[]
platformRoles UserPlatformRole[]
passwordResetTokens PasswordResetToken[]
@@map("users")
}
/// Relación many-to-many entre User y Tenant. Permite que un mismo user (p.ej.
/// un dueño/contador) pertenezca a varios tenants con distintos roles. Esta
/// tabla es la fuente de verdad del "¿a qué tenants tiene acceso este user?".
///
/// Durante la transición, `User.tenantId` y `User.rolId` se mantienen como
/// "default tenant" para login UX. El backfill inicial crea 1 membership por
/// user basado en esos campos. Cuando se agregue la UI de multi-tenant, los
/// nuevos accesos solo tocarán esta tabla.
model TenantMembership {
id Int @id @default(autoincrement())
userId String @map("user_id")
tenantId String @map("tenant_id")
rolId Int @map("rol_id")
isOwner Boolean @default(false) @map("is_owner")
active Boolean @default(true)
joinedAt DateTime @default(now()) @map("joined_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
rol Rol @relation(fields: [rolId], references: [id])
@@unique([userId, tenantId])
@@index([userId, active])
@@index([tenantId, active])
@@map("tenant_memberships")
}
model Rol {
id Int @id @default(autoincrement())
nombre String @unique @db.VarChar(20)
descripcion String?
createdAt DateTime @default(now()) @map("created_at")
memberships TenantMembership[]
@@map("roles")
}
model RefreshToken {
id String @id @default(uuid())
userId String @map("user_id")
token String @unique
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("refresh_tokens")
}
/// Tokens para recuperación de contraseña. Expiran en 1 hora, son single-use
/// (se marca `usedAt` al consumir). Al completar reset se invalidan todos los
/// refresh tokens del user — cierra todas sus sesiones forzando re-login.
model PasswordResetToken {
id String @id @default(uuid())
userId String @map("user_id")
token String @unique
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("password_reset_tokens")
}
enum Plan {
starter
business
business_ia
custom
enterprise
business_control
business_cloud
mi_empresa
mi_empresa_plus
}
enum VerticalProfile {
CONTABLE
JURIDICO
ARQUITECTURA
}
enum DbMode {
BYO
MANAGED
}
// ============================================
// Catálogo de Regímenes Fiscales SAT
// ============================================
model Regimen {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(3)
descripcion String
tipoPersona String @map("tipo_persona") @db.VarChar(20) // fisica, moral, ambos
activo Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
tenantIgnorados TenantRegimenIgnorado[]
tenantActivos TenantRegimenActivo[]
@@map("regimenes")
}
model TenantRegimenIgnorado {
id Int @id @default(autoincrement())
tenantId String @map("tenant_id")
regimenId Int @map("regimen_id")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
@@unique([tenantId, regimenId])
@@map("tenant_regimenes_ignorados")
}
model TenantRegimenActivo {
id Int @id @default(autoincrement())
tenantId String @map("tenant_id")
regimenId Int @map("regimen_id")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
@@unique([tenantId, regimenId])
@@map("tenant_regimenes_activos")
}
// ============================================
// Catálogo de Eventos Fiscales
// ============================================
model EventoFiscalCatalogo {
id Int @id @default(autoincrement())
titulo String
descripcion String?
tipo String @db.VarChar(20) // declaracion, pago, obligacion, informativa
diaBase Int @map("dia_base") // día del mes (17, 3, 31, etc.)
mesRelativo Int @default(1) @map("mes_relativo") // 1=mes posterior, 2=segundo mes posterior, 0=mes fijo
mesFijo Int? @map("mes_fijo") // para anuales: 2=feb, 3=mar, 4=abr
recurrencia String @default("mensual") @db.VarChar(20) // mensual, anual
usaExtensionRfc Boolean @default(false) @map("usa_extension_rfc")
regimenes String @default("todos") // 'todos' o CSV de claves: '601,603,612'
condicion String? @db.VarChar(50) // null, 'tiene_nomina', 'ingresos_4m'
activo Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
@@map("eventos_fiscales_catalogo")
}
/// Lista negra SAT (Art. 69-B CFF)
model ListaNegra {
id Int @id @default(autoincrement())
rfc String @unique @db.VarChar(13)
nombre String
situacion String @db.VarChar(30) // Definitivo, Presunto, Desvirtuado, Sentencia Favorable
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([rfc])
@@map("lista_negra")
}
/// Días inhábiles fiscales (festivos oficiales de México)
model DiaInhabil {
id Int @id @default(autoincrement())
fecha DateTime @unique @db.Date
nombre String
@@map("dias_inhabiles")
}
// ============================================
// ISR Tables
// ============================================
/// Tasas RESICO (Art. 113-E) - tasa plana por bracket mensual
model IsrResicoTasa {
id Int @id @default(autoincrement())
anio Int @map("anio")
montoMaximo Decimal @map("monto_maximo") @db.Decimal(18, 2)
porcentaje Decimal @db.Decimal(5, 2)
@@unique([anio, montoMaximo])
@@map("isr_resico_tasas")
}
/// Tarifa ISR progresiva (Art. 96) - mensual
model IsrTarifa {
id Int @id @default(autoincrement())
anio Int @map("anio")
limiteInferior Decimal @map("limite_inferior") @db.Decimal(18, 2)
limiteSuperior Decimal? @map("limite_superior") @db.Decimal(18, 2)
cuotaFija Decimal @map("cuota_fija") @db.Decimal(18, 2)
porcentajeExcedente Decimal @map("porcentaje_excedente") @db.Decimal(5, 2)
@@unique([anio, limiteInferior])
@@map("isr_tarifas")
}
/// Coeficiente de utilidad por tenant/año (no se sobrescribe)
model CoeficienteUtilidad {
id Int @id @default(autoincrement())
tenantId String @map("tenant_id")
anio Int @map("anio")
coeficiente Decimal @db.Decimal(10, 4)
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, anio])
@@map("coeficiente_utilidad")
}
// ============================================
// SAT Sync Models
// ============================================
model FielCredential {
id String @id @default(uuid())
tenantId String @unique @map("tenant_id")
rfc String @db.VarChar(13)
cerData Bytes @map("cer_data")
keyData Bytes @map("key_data")
keyPasswordEncrypted Bytes @map("key_password_encrypted")
cerIv Bytes @map("cer_iv")
cerTag Bytes @map("cer_tag")
keyIv Bytes @map("key_iv")
keyTag Bytes @map("key_tag")
passwordIv Bytes @map("password_iv")
passwordTag Bytes @map("password_tag")
serialNumber String? @map("serial_number") @db.VarChar(50)
validFrom DateTime @map("valid_from")
validUntil DateTime @map("valid_until")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("fiel_credentials")
}
model Subscription {
id String @id @default(uuid())
tenantId String @map("tenant_id")
plan Plan
mpPreapprovalId String? @map("mp_preapproval_id")
status String @default("pending")
amount Decimal @db.Decimal(10, 2)
frequency String @default("monthly")
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
// Cambio programado al próximo período (downgrades y cambios de frecuencia)
pendingPlan Plan? @map("pending_plan")
pendingFrequency String? @map("pending_frequency")
pendingEffectiveAt DateTime? @map("pending_effective_at")
// Upgrade inmediato en curso: preference MP esperando cobro prorateado.
// Cuando el webhook confirma el pago, se aplica el plan nuevo y se limpian estos campos.
upgradePreferenceId String? @map("upgrade_preference_id")
upgradeTargetPlan Plan? @map("upgrade_target_plan")
upgradeTargetAmount Decimal? @db.Decimal(10, 2) @map("upgrade_target_amount")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
payments Payment[]
addons SubscriptionAddon[]
@@index([tenantId])
@@index([status])
@@index([pendingEffectiveAt])
@@map("subscriptions")
}
model SubscriptionAddon {
id String @id @default(uuid())
subscriptionId String @map("subscription_id")
planAddonCatalogoId String @map("plan_addon_catalogo_id")
/// UUID del contribuyente (entidad_id en tenant BD) cuando el add-on
/// aplica a un RFC específico. NULL para add-ons a nivel tenant (módulos
/// globales, +RFCs, +timbres). Sin FK porque contribuyente vive en BD tenant.
contribuyenteId String? @map("contribuyente_id")
mpPreapprovalId String? @map("mp_preapproval_id")
status String @default("pending")
quantity Int @default(1)
amount Decimal @db.Decimal(10, 2)
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
subscription Subscription @relation(fields: [subscriptionId], references: [id])
planAddonCatalogo PlanAddonCatalogo @relation(fields: [planAddonCatalogoId], references: [id])
/// Sin UNIQUE compuesto: la validación de "un solo add-on activo por
/// (subscription, addon, contribuyente?)" queda a nivel aplicación
/// (findFirst en subscribeAddon), porque Postgres trata NULL!=NULL y no
/// hay forma trivial de enforcar unicidad con contribuyenteId opcional.
@@index([subscriptionId])
@@index([subscriptionId, contribuyenteId])
@@map("subscription_addons")
}
/// Roles de plataforma (staff interno de Horux 360) — ortogonales al rol per-tenant.
/// Un user puede tener 0, 1 o varios roles. `platform_admin` es el superrol.
/// Ver `docs/plans/2026-04-14-platform-admin-roles.md`.
enum PlatformRole {
platform_admin // Todo: precios, clientes, facturas, suscripciones, gestión de staff
platform_ti // Mismos permisos que admin (equipo de TI / tech ops). Diferencia solo en trazabilidad.
platform_support // Ver todos los tenants, resolver tickets, NO facturación/precios
platform_sales // Crear/editar tenants (onboarding), ver suscripciones, NO precios
platform_finance // Ver payments, emitir facturas manuales, editar precios, reportes fiscales
}
model UserPlatformRole {
id Int @id @default(autoincrement())
userId String @map("user_id")
role PlatformRole
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") // User.id de quien asignó (audit trail)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, role])
@@index([role])
@@map("user_platform_roles")
}
/// Registro de acciones críticas para auditoría (SAT compliance, forense, disputas).
/// Se instrumenta vía `utils/audit.ts` con helper fire-and-forget — un fallo al
/// escribir aquí NUNCA debe romper la acción principal.
model AuditLog {
id String @id @default(uuid())
userId String? @map("user_id")
tenantId String? @map("tenant_id")
action String @db.VarChar(64) // "price.updated", "subscription.cancelled", etc.
entityType String? @map("entity_type") @db.VarChar(32)
entityId String? @map("entity_id")
metadata Json? // before/after, ip, userAgent, contexto
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, createdAt])
@@index([tenantId, createdAt])
@@index([action, createdAt])
@@index([entityType, entityId])
@@map("audit_log")
}
/// Padrón persistente de RFCs que ya consumieron su prueba gratuita de 30 días.
/// Sobrevive al ciclo de vida del Tenant (si se borra/recrea, el RFC sigue aquí),
/// bloqueando el abuso de "registro nuevo con el mismo RFC para otro trial".
model TrialUsage {
id Int @id @default(autoincrement())
rfc String @unique @db.VarChar(13)
tenantId String? @map("tenant_id") // Tenant que consumió (null si el tenant se borró después)
startedAt DateTime @default(now()) @map("started_at")
@@map("trial_usages")
}
/// Precios editables de los planes (self-serve). Custom no se guarda aquí
/// porque cada cliente tiene su monto propio (lo fija el admin al crear tenant).
model PlanPrice {
id Int @id @default(autoincrement())
plan Plan
frequency String // "monthly" | "annual"
amount Decimal @db.Decimal(10, 2)
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([plan, frequency])
@@map("plan_prices")
}
/// Precios editables por admin global de los planes despacho.
/// Antes vivía en `DESPACHO_PLAN_PRICES` (catálogo estático en `@horux/shared`);
/// movido a BD para permitir actualización desde `/configuracion/precios-suscripcion`.
/// Si una fila no existe, `getPlanPrice` cae al catálogo estático como fallback.
model DespachoPlanPrice {
plan String @id // mi_empresa | mi_empresa_plus | business_control | business_cloud
monthly Decimal? @db.Decimal(10, 2)
firstYear Decimal @db.Decimal(10, 2) @map("first_year")
renewal Decimal @db.Decimal(10, 2)
permiteMonthly Boolean @default(false) @map("permite_monthly")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("despacho_plan_prices")
}
model PlanCatalogo {
id String @id @default(uuid())
codename String @unique @db.VarChar(50)
nombre String
verticalProfile VerticalProfile
precioBase Decimal @db.Decimal(10, 2) @map("precio_base")
frecuencia String @db.VarChar(10)
limits Json
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
@@map("plan_catalogo")
}
model PlanAddonCatalogo {
id String @id @default(uuid())
codename String @unique @db.VarChar(50)
nombre String
verticalProfile VerticalProfile?
precio Decimal @db.Decimal(10, 2)
frecuencia String @db.VarChar(10)
delta Json
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
subscriptionAddons SubscriptionAddon[]
@@map("plan_addon_catalogo")
}
model ConnectorHeartbeat {
id String @id @default(uuid())
tenantId String @map("tenant_id")
timestamp DateTime @default(now())
latencyMs Int @map("latency_ms")
version String @db.VarChar(20)
pgVersion String? @map("pg_version") @db.VarChar(50)
status String @db.VarChar(20)
errorMsg String? @map("error_msg")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId, timestamp])
@@map("connector_heartbeats")
}
enum PaymentKind {
subscription
timbres_pack
}
model Payment {
id String @id @default(uuid())
tenantId String @map("tenant_id")
subscriptionId String? @map("subscription_id")
mpPaymentId String? @map("mp_payment_id")
amount Decimal @db.Decimal(10, 2)
status String @default("pending")
paymentMethod String? @map("payment_method")
paidAt DateTime? @map("paid_at")
// Tipo de pago. subscription = cobro mensual/anual del plan.
// timbres_pack = compra de paquete de timbres adicionales.
kind PaymentKind @default(subscription)
// ID de la factura emitida auto por Facturapi. Null si no se facturó:
// primer pago (manual), trial sin monto, o fallo al emitir.
facturapiInvoiceId String? @map("facturapi_invoice_id")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
timbrePaquete TimbrePaquete?
@@index([tenantId])
@@index([subscriptionId])
@@map("payments")
}
/// Catálogo de paquetes de timbres adicionales vendibles. Precios editables
/// desde panel admin. Los 3 defaults (100/$200, 1000/$1400, 10000/$8600) se
/// insertan en seed idempotente.
model TimbrePaqueteCatalogo {
id Int @id @default(autoincrement())
cantidad Int @unique // 100, 1000, 10000
precio Decimal @db.Decimal(10, 2)
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("timbre_paquetes_catalogo")
}
/// Compra individual de timbres adicionales. Los timbres del plan (mensuales)
/// se rastrean en TimbreSuscripcion — esto es SOLO para los extras pagados.
/// Vigencia 1 año desde `adquiridoEn`. El orden de consumo es FIFO por
/// `expiraEn` (menor primero) para no desperdiciar paquetes próximos a vencer.
model TimbrePaquete {
id Int @id @default(autoincrement())
tenantId String @map("tenant_id")
paymentId String? @unique @map("payment_id") // Payment que lo compró; null si admin grant manual
cantidad Int // cuántos timbres tenía originalmente
usados Int @default(0)
precio Decimal @db.Decimal(10, 2) // precio pagado (historial, no cambia si el catálogo cambia)
adquiridoEn DateTime @default(now()) @map("adquirido_en")
expiraEn DateTime @map("expira_en") // adquiridoEn + 1 año
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
payment Payment? @relation(fields: [paymentId], references: [id])
@@index([tenantId, expiraEn])
@@map("timbre_paquetes")
}
model SatSyncJob {
id String @id @default(uuid())
tenantId String @map("tenant_id")
contribuyenteId String? @map("contribuyente_id")
type SatSyncType
status SatSyncStatus @default(pending)
dateFrom DateTime @map("date_from") @db.Date
dateTo DateTime @map("date_to") @db.Date
cfdiType CfdiSyncType? @map("cfdi_type")
satRequestId String? @map("sat_request_id") @db.VarChar(50)
satPackageIds String[] @map("sat_package_ids")
cfdisFound Int @default(0) @map("cfdis_found")
cfdisDownloaded Int @default(0) @map("cfdis_downloaded")
cfdisInserted Int @default(0) @map("cfdis_inserted")
cfdisUpdated Int @default(0) @map("cfdis_updated")
progressPercent Int @default(0) @map("progress_percent")
errorMessage String? @map("error_message")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
retryCount Int @default(0) @map("retry_count")
nextRetryAt DateTime? @map("next_retry_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([status])
@@index([status, nextRetryAt])
@@map("sat_sync_jobs")
}
enum SatSyncType {
initial
daily
incremental
}
enum SatSyncStatus {
pending
running
completed
failed
}
enum CfdiSyncType {
emitidos
recibidos
}
// ============================================
// Catálogos SAT para Facturación (CFDI 4.0)
// ============================================
model CatFormaPago {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(2)
descripcion String
@@map("cat_forma_pago")
}
model CatMetodoPago {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(3)
descripcion String
@@map("cat_metodo_pago")
}
model CatUsoCfdi {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(4)
descripcion String
personaFisica Boolean @default(true) @map("persona_fisica")
personaMoral Boolean @default(true) @map("persona_moral")
@@map("cat_uso_cfdi")
}
model CatMoneda {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(3)
descripcion String
decimales Int @default(2)
@@map("cat_moneda")
}
model CatClaveUnidad {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(10)
descripcion String
@@map("cat_clave_unidad")
}
model CatClaveProdServ {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(8)
descripcion String
@@index([descripcion])
@@map("cat_clave_prod_serv")
}
model CatObjetoImp {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(2)
descripcion String
@@map("cat_objeto_imp")
}
model CatTipoRelacion {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(2)
descripcion String
@@map("cat_tipo_relacion")
}
model CatExportacion {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(2)
descripcion String
@@map("cat_exportacion")
}
// ============================================
// Gestión de Timbres Facturapi
// ============================================
model TimbreSuscripcion {
id Int @id @default(autoincrement())
tenantId String @unique @map("tenant_id")
tipo String @db.VarChar(10) // mensual, anual
timbresLimite Int @map("timbres_limite") // 50 o 600
timbresUsados Int @default(0) @map("timbres_usados")
periodoInicio DateTime @map("periodo_inicio") @db.Date
periodoFin DateTime @map("periodo_fin") @db.Date
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("timbre_suscripciones")
}

559
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,559 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { Pool } from 'pg';
import { migrate } from '../src/config/tenant-migrations.js';
import { RESICO_TASAS, ISR_TARIFAS } from './isr-data.js';
import { EVENTOS_FISCALES, DIAS_INHABILES } from './eventos-fiscales-data.js';
import {
FORMAS_PAGO, METODOS_PAGO, USOS_CFDI, MONEDAS, CLAVES_UNIDAD,
OBJETOS_IMP, TIPOS_RELACION, EXPORTACIONES,
} from './catalogos-sat-data.js';
const prisma = new PrismaClient();
function parseDatabaseUrl(url: string) {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: parseInt(parsed.port || '5432'),
user: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
};
}
const REGIMENES_SAT = [
{ clave: '601', descripcion: 'General de Ley Personas Morales', tipoPersona: 'moral' },
{ clave: '603', descripcion: 'Personas Morales con Fines no Lucrativos', tipoPersona: 'moral' },
{ clave: '605', descripcion: 'Sueldos y Salarios e Ingresos Asimilados a Salarios', tipoPersona: 'fisica' },
{ clave: '606', descripcion: 'Arrendamiento', tipoPersona: 'fisica' },
{ clave: '607', descripcion: 'Régimen de Enajenación o Adquisición de Bienes', tipoPersona: 'fisica' },
{ clave: '608', descripcion: 'Demás ingresos', tipoPersona: 'fisica' },
{ clave: '610', descripcion: 'Residentes en el Extranjero sin Establecimiento Permanente en México', tipoPersona: 'ambos' },
{ clave: '611', descripcion: 'Ingresos por Dividendos (socios y accionistas)', tipoPersona: 'fisica' },
{ clave: '612', descripcion: 'Personas Físicas con Actividades Empresariales y Profesionales', tipoPersona: 'fisica' },
{ clave: '614', descripcion: 'Ingresos por intereses', tipoPersona: 'fisica' },
{ clave: '615', descripcion: 'Régimen de los ingresos por obtención de premios', tipoPersona: 'fisica' },
{ clave: '616', descripcion: 'Sin obligaciones fiscales', tipoPersona: 'ambos' },
{ clave: '620', descripcion: 'Sociedades Cooperativas de Producción que optan por diferir sus ingresos', tipoPersona: 'moral' },
{ clave: '621', descripcion: 'Incorporación Fiscal', tipoPersona: 'fisica' },
{ clave: '622', descripcion: 'Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras', tipoPersona: 'ambos' },
{ clave: '623', descripcion: 'Opcional para Grupos de Sociedades', tipoPersona: 'moral' },
{ clave: '624', descripcion: 'Coordinados', tipoPersona: 'moral' },
{ clave: '625', descripcion: 'Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas', tipoPersona: 'fisica' },
{ clave: '626', descripcion: 'Régimen Simplificado de Confianza', tipoPersona: 'ambos' },
];
async function main() {
console.log('🌱 Seeding database...');
// Seed regimenes catalog
for (const r of REGIMENES_SAT) {
await prisma.regimen.upsert({
where: { clave: r.clave },
update: { descripcion: r.descripcion, tipoPersona: r.tipoPersona },
create: r,
});
}
console.log(`${REGIMENES_SAT.length} regímenes fiscales SAT cargados`);
// Seed ISR tables — limpiar y recrear
await prisma.isrResicoTasa.deleteMany();
await prisma.isrTarifa.deleteMany();
for (const anio of [2020, 2021, 2022, 2023, 2024, 2025, 2026]) {
if (anio >= 2022) {
await prisma.isrResicoTasa.createMany({
data: RESICO_TASAS.map(t => ({ anio, montoMaximo: t.montoMaximo, porcentaje: t.porcentaje })),
});
}
const tarifas = ISR_TARIFAS[anio];
if (tarifas) {
await prisma.isrTarifa.createMany({
data: tarifas.map(t => ({
anio,
limiteInferior: t.li,
limiteSuperior: t.ls,
cuotaFija: t.cf,
porcentajeExcedente: t.pe,
})),
});
}
}
console.log('✅ Tablas ISR 2020-2026 cargadas');
// Seed eventos fiscales catálogo
await prisma.eventoFiscalCatalogo.deleteMany();
await prisma.eventoFiscalCatalogo.createMany({
data: EVENTOS_FISCALES.map(e => ({
titulo: e.titulo,
tipo: e.tipo,
diaBase: e.diaBase,
mesRelativo: e.mesRelativo,
mesFijo: (e as any).mesFijo || null,
recurrencia: e.recurrencia,
usaExtensionRfc: e.usaExtensionRfc,
regimenes: e.regimenes,
condicion: e.condicion || null,
})),
});
console.log(`${EVENTOS_FISCALES.length} eventos fiscales cargados`);
// Seed días inhábiles
await prisma.diaInhabil.deleteMany();
await prisma.diaInhabil.createMany({
data: DIAS_INHABILES.map(d => ({ fecha: new Date(d.fecha), nombre: d.nombre })),
skipDuplicates: true,
});
console.log(`${DIAS_INHABILES.length} días inhábiles cargados (2020-2027)`);
// Seed catálogos SAT para facturación
for (const fp of FORMAS_PAGO) {
await prisma.catFormaPago.upsert({ where: { clave: fp.clave }, update: { descripcion: fp.descripcion }, create: fp });
}
console.log(`${FORMAS_PAGO.length} formas de pago cargadas`);
for (const mp of METODOS_PAGO) {
await prisma.catMetodoPago.upsert({ where: { clave: mp.clave }, update: { descripcion: mp.descripcion }, create: mp });
}
console.log(`${METODOS_PAGO.length} métodos de pago cargados`);
for (const u of USOS_CFDI) {
await prisma.catUsoCfdi.upsert({ where: { clave: u.clave }, update: { descripcion: u.descripcion, personaFisica: u.personaFisica, personaMoral: u.personaMoral }, create: u });
}
console.log(`${USOS_CFDI.length} usos CFDI cargados`);
for (const m of MONEDAS) {
await prisma.catMoneda.upsert({ where: { clave: m.clave }, update: { descripcion: m.descripcion, decimales: m.decimales }, create: m });
}
console.log(`${MONEDAS.length} monedas cargadas`);
for (const cu of CLAVES_UNIDAD) {
await prisma.catClaveUnidad.upsert({ where: { clave: cu.clave }, update: { descripcion: cu.descripcion }, create: cu });
}
console.log(`${CLAVES_UNIDAD.length} claves de unidad cargadas`);
for (const oi of OBJETOS_IMP) {
await prisma.catObjetoImp.upsert({ where: { clave: oi.clave }, update: { descripcion: oi.descripcion }, create: oi });
}
console.log(`${OBJETOS_IMP.length} objetos de impuesto cargados`);
for (const tr of TIPOS_RELACION) {
await prisma.catTipoRelacion.upsert({ where: { clave: tr.clave }, update: { descripcion: tr.descripcion }, create: tr });
}
console.log(`${TIPOS_RELACION.length} tipos de relación cargados`);
for (const ex of EXPORTACIONES) {
await prisma.catExportacion.upsert({ where: { clave: ex.clave }, update: { descripcion: ex.descripcion }, create: ex });
}
console.log(`${EXPORTACIONES.length} exportaciones cargadas`);
// Seed precios de planes (editables vía BD — custom no se incluye, se fija por tenant)
const PLAN_PRICES = [
{ plan: 'starter' as const, frequency: 'monthly', amount: 199 },
{ plan: 'starter' as const, frequency: 'annual', amount: 1990 },
{ plan: 'business' as const, frequency: 'monthly', amount: 480 },
{ plan: 'business' as const, frequency: 'annual', amount: 4800 },
{ plan: 'business_ia' as const, frequency: 'monthly', amount: 780 },
{ plan: 'business_ia' as const, frequency: 'annual', amount: 7800 },
{ plan: 'enterprise' as const, frequency: 'monthly', amount: 900 },
{ plan: 'enterprise' as const, frequency: 'annual', amount: 9000 },
];
for (const p of PLAN_PRICES) {
await prisma.planPrice.upsert({
where: { plan_frequency: { plan: p.plan, frequency: p.frequency } },
update: { amount: p.amount },
create: p,
});
}
console.log(`${PLAN_PRICES.length} precios de planes cargados`);
// Catálogo de paquetes de timbres adicionales. Editables desde panel admin.
// Se crean con upsert por `cantidad` (unique) — permite reejecutar seed sin
// sobrescribir precios ya ajustados manualmente: si el row existe, update
// NO toca el precio (solo active + updatedAt si hace falta), sólo lo crea
// si no existía. Si se quiere forzar reset de precios, borrar las filas.
const TIMBRE_PAQUETES = [
{ cantidad: 100, precio: 200 },
{ cantidad: 1000, precio: 1400 },
{ cantidad: 10000, precio: 8600 },
];
for (const p of TIMBRE_PAQUETES) {
await prisma.timbrePaqueteCatalogo.upsert({
where: { cantidad: p.cantidad },
update: {}, // No tocamos `precio` si ya existe (admin pudo editarlo)
create: { cantidad: p.cantidad, precio: p.precio, active: true },
});
}
console.log(`${TIMBRE_PAQUETES.length} paquetes de timbres en catálogo`);
const databaseName = 'horux_ede123456ab1';
// Create demo tenant
const tenant = await prisma.tenant.upsert({
where: { rfc: 'EDE123456AB1' },
update: {},
create: {
nombre: 'Empresa Demo SA de CV',
rfc: 'EDE123456AB1',
plan: 'business',
databaseName,
cfdiLimit: 500,
usersLimit: 3,
},
});
console.log('✅ Tenant created:', tenant.nombre);
// Migración: renombra el rol legacy 'admin' a 'owner' si sobrevive de un seed viejo.
// Idempotente (no-op si ya se renombró o nunca existió).
await prisma.$executeRawUnsafe(`UPDATE roles SET nombre = 'owner' WHERE nombre = 'admin'`);
// Backfill de trial_usages para tenants que ya consumieron su trial antes de que
// existiera esta tabla. Idempotente: ON CONFLICT DO NOTHING.
await prisma.$executeRawUnsafe(`
INSERT INTO trial_usages (rfc, tenant_id, started_at)
SELECT UPPER(rfc), id, COALESCE(created_at, NOW())
FROM tenants
WHERE trial_ends_at IS NOT NULL
ON CONFLICT (rfc) DO NOTHING
`);
// Backfill de user_platform_roles: los owners del tenant HTS240708LJA se
// convierten automáticamente en platform_admin. Esto preserva el comportamiento
// anterior (admin global por RFC) al mismo tiempo que abre la puerta al modelo
// de roles granulares. Idempotente.
await prisma.$executeRawUnsafe(`
INSERT INTO user_platform_roles (user_id, role, created_at)
SELECT u.id, 'platform_admin'::"PlatformRole", NOW()
FROM users u
JOIN tenants t ON u.tenant_id = t.id
JOIN roles r ON u.rol_id = r.id
WHERE t.rfc = 'HTS240708LJA' AND r.nombre = 'owner'
ON CONFLICT (user_id, role) DO NOTHING
`);
// Backfill de tenant_memberships: cada user existente genera una membership
// con su tenant y rol actuales. isOwner = true si su rol es 'owner' (u 'cfo',
// que es equivalente en permisos). Esto es el fundamento del modelo multi-tenant
// — durante la transición, User.tenantId sigue siendo el "default tenant" para
// login UX, pero las autorizaciones verdaderas vienen de esta tabla.
// Idempotente: ON CONFLICT evita duplicados al re-correr seed.
await prisma.$executeRawUnsafe(`
INSERT INTO tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at)
SELECT u.id, u.tenant_id, u.rol_id, (r.nombre IN ('owner', 'cfo')), u.active, u.created_at
FROM users u
JOIN roles r ON u.rol_id = r.id
ON CONFLICT (user_id, tenant_id) DO NOTHING
`);
// Seed roles
const rolesData = [
{ nombre: 'owner', descripcion: 'Dueño - acceso completo' },
{ nombre: 'cfo', descripcion: 'CFO - acceso completo (mismo nivel que el dueño)' },
{ nombre: 'contador', descripcion: 'Contador - dashboard, CFDI, impuestos, calendario, alertas, facturación' },
{ nombre: 'auxiliar', descripcion: 'Auxiliar - mismos permisos que contador' },
{ nombre: 'visor', descripcion: 'Visor - solo lectura de CFDI, impuestos, calendario, alertas' },
];
for (const r of rolesData) {
await prisma.rol.upsert({
where: { nombre: r.nombre },
update: { descripcion: r.descripcion },
create: r,
});
}
// Seed despacho roles
await prisma.rol.upsert({
where: { nombre: 'supervisor' },
update: {},
create: { id: 9, nombre: 'supervisor', descripcion: 'Supervisor de despacho — titular de RFCs, crea carteras' },
});
await prisma.rol.upsert({
where: { nombre: 'cliente' },
update: {},
create: { id: 10, nombre: 'cliente', descripcion: 'Cliente visor externo — acceso read-only a sus RFCs' },
});
const roles = await prisma.rol.findMany();
const rolMap = new Map(roles.map(r => [r.nombre, r.id]));
console.log(`${roles.length} roles cargados`);
// Create demo users
const passwordHash = await bcrypt.hash('demo123', 12);
const users = [
{ email: 'admin@demo.com', nombre: 'Dueño Demo', rolNombre: 'owner' },
{ email: 'contador@demo.com', nombre: 'Contador Demo', rolNombre: 'contador' },
{ email: 'visor@demo.com', nombre: 'Visor Demo', rolNombre: 'visor' },
];
for (const userData of users) {
const user = await prisma.user.upsert({
where: { email: userData.email },
update: {},
create: {
tenantId: tenant.id,
email: userData.email,
passwordHash,
nombre: userData.nombre,
rolId: rolMap.get(userData.rolNombre)!,
},
include: { rol: true },
});
console.log(`✅ User created: ${user.email} (${user.rol.nombre})`);
}
// Create tenant database
const dbConfig = parseDatabaseUrl(process.env.DATABASE_URL!);
const adminPool = new Pool({ ...dbConfig, database: 'postgres', max: 1 });
try {
const exists = await adminPool.query(
`SELECT 1 FROM pg_database WHERE datname = $1`,
[databaseName]
);
if (exists.rows.length === 0) {
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
console.log(`✅ Tenant database created: ${databaseName}`);
} else {
console.log(` Tenant database already exists: ${databaseName}`);
}
} finally {
await adminPool.end();
}
// Create tables in tenant database
const tenantPool = new Pool({ ...dbConfig, database: databaseName, max: 1 });
try {
// Reset tenant tables so the re-seed parte de cero. Luego corremos las
// migraciones (fuente única de verdad del schema tenant) para garantizar
// que queden todas las tablas y columnas actuales.
await tenantPool.query(`
DROP TABLE IF EXISTS cfdi_conceptos CASCADE;
DROP TABLE IF EXISTS cfdis CASCADE;
DROP TABLE IF EXISTS conciliaciones CASCADE;
DROP TABLE IF EXISTS bancos CASCADE;
DROP TABLE IF EXISTS recordatorios CASCADE;
DROP TABLE IF EXISTS alertas CASCADE;
DROP TABLE IF EXISTS rfcs CASCADE;
DROP TABLE IF EXISTS opiniones_cumplimiento CASCADE;
DROP TABLE IF EXISTS schema_migrations;
`);
await migrate(tenantPool, tenant.rfc);
console.log('✅ Tenant schema aplicado vía migraciones');
// Bloque legacy de CREATE TABLE / CREATE INDEX retirado: vive ahora en
// `apps/api/src/migrations/tenant/*.sql` (fuente única de verdad).
// Insert demo CFDIs with new structure
const cfdiTypes = ['EMITIDO', 'RECIBIDO'];
const tipoComprobantes: Record<string, string> = { EMITIDO: 'I', RECIBIDO: 'I' };
const rfcs = ['XAXX010101000', 'MEXX020202000', 'AAXX030303000', 'BBXX040404000'];
const nombres = ['Cliente Demo SA', 'Proveedor ABC', 'Servicios XYZ', 'Materiales 123'];
for (let i = 0; i < 50; i++) {
const tipo = cfdiTypes[i % 2];
const rfcIndex = i % 4;
const subtotal = Math.floor(Math.random() * 50000) + 1000;
const iva = subtotal * 0.16;
const total = subtotal + iva;
const daysAgo = Math.floor(Math.random() * 180);
const fecha = new Date();
fecha.setDate(fecha.getDate() - daysAgo);
const year = String(fecha.getFullYear());
const month = String(fecha.getMonth() + 1).padStart(2, '0');
await tenantPool.query(`
INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, subtotal_mxn, descuento, descuento_mxn,
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
metodo_pago, iva_traslado, iva_traslado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)
ON CONFLICT (uuid) DO NOTHING
`, [
year, month, tipo, crypto.randomUUID(), 'A', String(1000 + i),
'Vigente', fecha,
tipo === 'EMITIDO' ? 'EDE123456AB1' : rfcs[rfcIndex],
tipo === 'EMITIDO' ? 'Empresa Demo SA de CV' : nombres[rfcIndex],
tipo === 'RECIBIDO' ? 'EDE123456AB1' : rfcs[rfcIndex],
tipo === 'RECIBIDO' ? 'Empresa Demo SA de CV' : nombres[rfcIndex],
subtotal, subtotal, 0, 0,
total, total, 'MXN', 1, tipoComprobantes[tipo],
'PUE', iva, iva,
'601', '601',
]);
}
console.log('✅ Demo CFDIs created (50)');
// Insert demo conceptos for each CFDI
const { rows: allCfdis } = await tenantPool.query(`SELECT id FROM cfdis`);
const productos = ['Servicio de consultoría', 'Licencia de software', 'Soporte técnico', 'Desarrollo web', 'Capacitación'];
for (const c of allCfdis) {
const numConceptos = Math.floor(Math.random() * 3) + 1;
for (let j = 0; j < numConceptos; j++) {
const cantidad = Math.floor(Math.random() * 5) + 1;
const valorUnitario = Math.floor(Math.random() * 5000) + 500;
const importe = cantidad * valorUnitario;
const iva = importe * 0.16;
await tenantPool.query(`
INSERT INTO cfdi_conceptos (
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
valor_unitario, valor_unitario_mxn,
importe, importe_mxn, descuento, descuento_mxn,
iva_traslado, iva_traslado_mxn
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
`, [
c.id,
'84111506', productos[j % productos.length], cantidad, 'E48', 'Servicio',
valorUnitario, valorUnitario,
importe, importe, 0, 0,
iva, iva,
]);
}
}
console.log('✅ Demo conceptos created');
} finally {
await tenantPool.end();
}
// Seed plan catalog for CONTABLE vertical
const planCatalogoData = [
{
codename: 'trial_contable',
nombre: 'Trial Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 0,
frecuencia: 'mensual',
limits: { maxRfcs: 3, maxUsers: 1, timbresIncluidosMes: 20, features: ['dashboard', 'cfdi_basic', 'iva_isr'] },
},
{
codename: 'starter_contable',
nombre: 'Starter Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 490,
frecuencia: 'mensual',
limits: { maxRfcs: 10, maxUsers: 3, timbresIncluidosMes: 50, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario'] },
},
{
codename: 'business_contable',
nombre: 'Business Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 1290,
frecuencia: 'mensual',
limits: { maxRfcs: 50, maxUsers: 10, timbresIncluidosMes: 200, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario', 'reportes', 'conciliacion', 'documentos', 'facturacion'] },
},
{
codename: 'enterprise_contable',
nombre: 'Enterprise Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 2990,
frecuencia: 'mensual',
limits: { maxRfcs: -1, maxUsers: -1, timbresIncluidosMes: 600, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario', 'reportes', 'conciliacion', 'documentos', 'facturacion', 'api'] },
},
];
for (const p of planCatalogoData) {
await prisma.planCatalogo.upsert({
where: { codename: p.codename },
update: { nombre: p.nombre, precioBase: p.precioBase, limits: p.limits },
create: p,
});
}
console.log('✓ Plan catalog seeded (4 plans CONTABLE)');
// Seed addon catalog
const addonCatalogoData = [
{
codename: 'rfcs_extra_10',
nombre: '+10 RFCs adicionales',
verticalProfile: 'CONTABLE' as const,
precio: 190,
frecuencia: 'mensual',
delta: { maxRfcs: 10 },
},
{
codename: 'rfcs_extra_50',
nombre: '+50 RFCs adicionales',
verticalProfile: 'CONTABLE' as const,
precio: 690,
frecuencia: 'mensual',
delta: { maxRfcs: 50 },
},
{
codename: 'timbres_extra_500',
nombre: '+500 timbres mensuales',
precio: 490,
frecuencia: 'mensual',
delta: { timbresIncluidosMes: 500 },
},
{
codename: 'modulo_ia',
nombre: 'Módulo IA Fiscal',
precio: 390,
frecuencia: 'mensual',
delta: { features: ['ia_lolita'] },
},
{
// Lolita IA activable por contribuyente específico del despacho.
// SubscriptionAddon.contribuyenteId apunta al RFC que lo contrata.
// Cobro mensual en preapproval propio (la licencia del despacho es anual;
// el add-on va en ciclo independiente).
codename: 'lolita_ia_contribuyente',
nombre: 'Lolita IA (por contribuyente)',
verticalProfile: 'CONTABLE' as const,
precio: 250,
frecuencia: 'mensual',
delta: { features: ['ia_lolita'] },
},
{
// Contribuyente adicional para planes Business Control y Enterprise
// (ambos incluyen 100 base). Se cobra automáticamente según overage; no
// requiere opt-in, pero se modela como add-on para que el preapproval MP
// lo cubra. El codename mantiene el sufijo "business_cloud" por compat
// con suscripciones existentes; el nombre display ya es genérico.
codename: 'contribuyente_extra_business_cloud',
nombre: 'Contribuyente adicional (RFC extra)',
verticalProfile: 'CONTABLE' as const,
precio: 45,
frecuencia: 'mensual',
delta: { maxRfcs: 1 },
},
];
for (const a of addonCatalogoData) {
await prisma.planAddonCatalogo.upsert({
where: { codename: a.codename },
update: { nombre: a.nombre, precio: a.precio, delta: a.delta },
create: { ...a, verticalProfile: a.verticalProfile ?? null },
});
}
console.log('✓ Addon catalog seeded (6 addons)');
console.log('\n🎉 Seed completed successfully!');
console.log('\n📝 Demo credentials:');
console.log(' Admin: admin@demo.com / demo123');
console.log(' Contador: contador@demo.com / demo123');
console.log(' Visor: visor@demo.com / demo123');
}
main()
.catch((e) => {
console.error('Error seeding database:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,158 @@
/**
* Backfill de cfdis.contribuyente_id para los despachos.
*
* Asocia CFDIs huérfanos (contribuyente_id NULL) con el contribuyente cuyo RFC
* coincide con rfc_emisor (si type='EMITIDO') o rfc_receptor (si type='RECIBIDO').
*
* Causa raíz: retry path de sat.service.ts construía SyncContext sin
* contribuyenteId (bug fixed 2026-04-20).
*
* Idempotente: solo actualiza filas con contribuyente_id IS NULL y match único
* por RFC. Si no hay contribuyentes en el tenant (Horux360 clásico), no-op.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts --dry # reporta sin escribir
*/
import { prisma, tenantDb } from '../src/config/database.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
interface PerTenantResult {
tenantId: string;
rfc: string;
databaseName: string;
contribuyentesCount: number;
updated: number;
perContribuyente: Array<{ rfc: string; entidadId: string; rows: number }>;
error?: string;
}
async function backfillTenant(
tenantId: string,
rfc: string,
databaseName: string,
): Promise<PerTenantResult> {
const result: PerTenantResult = {
tenantId,
rfc,
databaseName,
contribuyentesCount: 0,
updated: 0,
perContribuyente: [],
};
const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows: contribs } = await pool.query<{ entidad_id: string; rfc: string }>(
`SELECT entidad_id, rfc FROM contribuyentes`,
);
result.contribuyentesCount = contribs.length;
if (contribs.length === 0) return result;
const client = await pool.connect();
try {
await client.query('BEGIN');
const sql = `
UPDATE cfdis c
SET contribuyente_id = cnt.entidad_id
FROM contribuyentes cnt
WHERE c.contribuyente_id IS NULL
AND (
(c.type = 'EMITIDO' AND cnt.rfc = c.rfc_emisor) OR
(c.type = 'RECIBIDO' AND cnt.rfc = c.rfc_receptor)
)
RETURNING cnt.entidad_id as "entidadId", cnt.rfc as "rfcContrib"
`;
const { rows: updated } = await client.query<{ entidadId: string; rfcContrib: string }>(sql);
result.updated = updated.length;
const byContrib = new Map<string, { rfc: string; rows: number }>();
for (const row of updated) {
const cur = byContrib.get(row.entidadId);
if (cur) cur.rows += 1;
else byContrib.set(row.entidadId, { rfc: row.rfcContrib, rows: 1 });
}
result.perContribuyente = Array.from(byContrib.entries()).map(([entidadId, v]) => ({
entidadId,
rfc: v.rfc,
rows: v.rows,
}));
if (DRY_RUN) {
await client.query('ROLLBACK');
} else {
await client.query('COMMIT');
}
} catch (err: any) {
await client.query('ROLLBACK').catch(() => {});
result.error = err?.message || String(err);
} finally {
client.release();
}
return result;
}
async function main() {
console.log(`=== Backfill cfdis.contribuyente_id ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
const results: PerTenantResult[] = [];
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] (${t.databaseName}) ... `);
try {
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
results.push(r);
if (r.error) {
console.log(`ERROR: ${r.error}`);
} else if (r.contribuyentesCount === 0) {
console.log(`sin contribuyentes (skip)`);
} else {
console.log(`${r.contribuyentesCount} contribs, ${r.updated} CFDIs backfill`);
for (const pc of r.perContribuyente) {
console.log(` ${pc.rfc}: ${pc.rows}`);
}
}
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
results.push({
tenantId: t.id,
rfc: t.rfc,
databaseName: t.databaseName,
contribuyentesCount: 0,
updated: 0,
perContribuyente: [],
error: err?.message || String(err),
});
}
}
const totalUpdated = results.reduce((s, r) => s + r.updated, 0);
const tenantsTouched = results.filter(r => r.updated > 0).length;
const tenantsFailed = results.filter(r => r.error).length;
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${results.length}`);
console.log(` Tenants con backfill: ${tenantsTouched}`);
console.log(` CFDIs actualizados: ${totalUpdated}${DRY_RUN ? ' (rolled back)' : ''}`);
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
await prisma.$disconnect();
process.exit(tenantsFailed > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,209 @@
/**
* Backfill de cfdis.cfdi_tipo_relacion + cfdis.cfdis_relacionados desde
* xml_original para CFDIs pre-migración 032.
*
* Criterio: WHERE xml_original IS NOT NULL AND cfdi_tipo_relacion IS NULL.
* Re-usa `parseXml()` para mantener la lógica de extracción idéntica al sync.
* Solo escribe si el parser extrae `cfdiTipoRelacion` no-nulo — los CFDIs sin
* CfdiRelacionados se siguen dejando con NULL (distinguible de "no procesado"
* via el filtro `cfdi_tipo_relacion IS NULL` porque el WHERE al final del run
* ya no los va a volver a tocar — pero cada invocación empieza desde el mismo
* filtro, por eso es idempotente: los sin-relación se re-parsean cada vez pero
* no se escribe nada).
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdis-relaciones.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdis-relaciones.ts --dry # reporta sin escribir
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { parseXml } from '../src/services/sat/sat-parser.service.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
interface PerTenantResult {
tenantId: string;
rfc: string;
databaseName: string;
scanned: number;
parsedOk: number;
parseFailed: number;
withRelation: number;
updated: number;
byTipoRelacion: Record<string, number>;
error?: string;
}
async function backfillTenant(
tenantId: string,
rfc: string,
databaseName: string,
): Promise<PerTenantResult> {
const result: PerTenantResult = {
tenantId,
rfc,
databaseName,
scanned: 0,
parsedOk: 0,
parseFailed: 0,
withRelation: 0,
updated: 0,
byTipoRelacion: {},
};
const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows } = await pool.query<{
id: number;
uuid: string;
type: string;
xml_original: string | null;
}>(
`SELECT id, uuid, type, xml_original
FROM cfdis
WHERE xml_original IS NOT NULL
AND cfdi_tipo_relacion IS NULL
ORDER BY id`,
);
result.scanned = rows.length;
if (rows.length === 0) return result;
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const row of rows) {
if (!row.xml_original) continue;
const downloadType = row.type === 'EMITIDO' ? 'emitidos' : 'recibidos';
let parsed;
try {
parsed = parseXml(row.xml_original, downloadType);
} catch {
result.parseFailed++;
continue;
}
if (!parsed) {
result.parseFailed++;
continue;
}
result.parsedOk++;
if (!parsed.cfdiTipoRelacion) continue;
result.withRelation++;
const tr = parsed.cfdiTipoRelacion;
result.byTipoRelacion[tr] = (result.byTipoRelacion[tr] || 0) + 1;
await client.query(
`UPDATE cfdis
SET cfdi_tipo_relacion = $2,
cfdis_relacionados = $3,
actualizado_en = NOW()
WHERE id = $1`,
[row.id, parsed.cfdiTipoRelacion, parsed.cfdisRelacionados],
);
result.updated++;
}
if (DRY_RUN) {
await client.query('ROLLBACK');
} else {
await client.query('COMMIT');
}
} catch (err: any) {
await client.query('ROLLBACK').catch(() => {});
result.error = err?.message || String(err);
} finally {
client.release();
}
return result;
}
async function main() {
console.log(`=== Backfill cfdis CfdiRelacionados ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
const results: PerTenantResult[] = [];
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] (${t.databaseName}) ... `);
try {
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
results.push(r);
if (r.error) {
console.log(`ERROR: ${r.error}`);
} else if (r.scanned === 0) {
console.log(`sin CFDIs candidatos (skip)`);
} else {
const tiposStr = Object.entries(r.byTipoRelacion)
.sort((a, b) => b[1] - a[1])
.map(([tr, n]) => `${tr}:${n}`)
.join(', ');
console.log(
`scan=${r.scanned} parsed=${r.parsedOk} fail=${r.parseFailed} rel=${r.withRelation} upd=${r.updated}${
tiposStr ? ` [${tiposStr}]` : ''
}`,
);
}
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
results.push({
tenantId: t.id,
rfc: t.rfc,
databaseName: t.databaseName,
scanned: 0,
parsedOk: 0,
parseFailed: 0,
withRelation: 0,
updated: 0,
byTipoRelacion: {},
error: err?.message || String(err),
});
}
}
const totalScanned = results.reduce((s, r) => s + r.scanned, 0);
const totalUpdated = results.reduce((s, r) => s + r.updated, 0);
const totalParseFailed = results.reduce((s, r) => s + r.parseFailed, 0);
const tenantsTouched = results.filter(r => r.updated > 0).length;
const tenantsFailed = results.filter(r => r.error).length;
const tiposGlobales: Record<string, number> = {};
for (const r of results) {
for (const [tr, n] of Object.entries(r.byTipoRelacion)) {
tiposGlobales[tr] = (tiposGlobales[tr] || 0) + n;
}
}
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${results.length}`);
console.log(` Tenants con backfill: ${tenantsTouched}`);
console.log(` CFDIs escaneados: ${totalScanned}`);
console.log(` CFDIs actualizados: ${totalUpdated}${DRY_RUN ? ' (rolled back)' : ''}`);
if (totalParseFailed > 0) console.log(` CFDIs parse falló: ${totalParseFailed}`);
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
if (Object.keys(tiposGlobales).length > 0) {
console.log(` Desglose TipoRelacion:`);
for (const [tr, n] of Object.entries(tiposGlobales).sort((a, b) => b[1] - a[1])) {
console.log(` ${tr}: ${n}`);
}
}
await prisma.$disconnect();
process.exit(tenantsFailed > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,126 @@
/**
* Backfill one-shot: completa los campos de emisor/subtotal/IVA/XML en las
* filas de `cfdis` con `source='facturapi'` que fueron insertadas por la
* versión buggy del controller (previo al fix 2026-04-24).
*
* Descarga el XML real de Facturapi, lo parsea con el mismo parser SAT,
* upsertea la fila de `rfcs` del emisor, y actualiza la fila de `cfdis`.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { downloadXmlContribuyente } from '../src/services/contribuyente-facturapi.service.js';
import * as facturapiService from '../src/services/facturapi.service.js';
import { parseXml } from '../src/services/sat/sat-parser.service.js';
const TENANT_RFC = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC },
select: { id: true, databaseName: true },
});
if (!tenant) {
console.log(`Tenant ${TENANT_RFC} no encontrado`);
return;
}
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows: pendientes } = await pool.query<{
id: number;
uuid: string;
facturapi_id: string;
contribuyente_id: string | null;
rfc_emisor: string | null;
}>(
`SELECT id, uuid, facturapi_id, contribuyente_id, rfc_emisor
FROM cfdis
WHERE source = 'facturapi'
AND (COALESCE(rfc_emisor, '') = '' OR xml_original IS NULL OR subtotal = 0)
ORDER BY fecha_emision ASC`,
);
console.log(`Encontradas ${pendientes.length} CFDIs Facturapi a backfillear en ${TENANT_RFC}\n`);
let ok = 0;
let fail = 0;
for (const row of pendientes) {
try {
console.log(`\n[${row.uuid}] facturapi_id=${row.facturapi_id} contrib=${row.contribuyente_id}`);
const xmlBuffer = row.contribuyente_id
? await downloadXmlContribuyente(pool, row.contribuyente_id, row.facturapi_id)
: await facturapiService.downloadXml(tenant.id, row.facturapi_id);
const xmlString = xmlBuffer.toString('utf-8');
const parsed = parseXml(xmlString, 'emitidos');
if (!parsed) {
console.log(` ⚠️ Parser retornó null — skip`);
fail++;
continue;
}
console.log(` emisor=${parsed.rfcEmisor} (${parsed.nombreEmisor}, régimen ${parsed.regimenFiscalEmisor})`);
console.log(` receptor=${parsed.rfcReceptor} (${parsed.nombreReceptor}, régimen ${parsed.regimenFiscalReceptor})`);
console.log(` subtotal=${parsed.subtotal} total=${parsed.total} iva_traslado=${parsed.ivaTraslado}`);
// Upsert rfcs emisor
const { rows: [emisorRow] } = await pool.query(
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
RETURNING id`,
[parsed.rfcEmisor, parsed.nombreEmisor || null, parsed.regimenFiscalEmisor || null],
);
// Upsert rfcs receptor
const { rows: [receptorRow] } = await pool.query(
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
RETURNING id`,
[parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null],
);
await pool.query(
`UPDATE cfdis SET
fecha_cert_sat = $2,
rfc_emisor_id = $3, rfc_emisor = $4, nombre_emisor = $5,
regimen_fiscal_emisor = $6,
rfc_receptor_id = $7, rfc_receptor = $8, nombre_receptor = $9,
regimen_fiscal_receptor = $10,
subtotal = $11, subtotal_mxn = $11,
total = $12, total_mxn = $12,
iva_traslado = $13, iva_traslado_mxn = $13,
iva_retencion = $14, iva_retencion_mxn = $14,
xml_original = $15,
serie = COALESCE($16, serie), folio = COALESCE($17, folio)
WHERE id = $1`,
[
row.id,
parsed.fechaCertSat,
emisorRow.id, parsed.rfcEmisor, parsed.nombreEmisor,
parsed.regimenFiscalEmisor,
receptorRow.id, parsed.rfcReceptor, parsed.nombreReceptor,
parsed.regimenFiscalReceptor,
parsed.subtotal,
parsed.total,
parsed.ivaTraslado,
parsed.ivaRetencion,
xmlString,
parsed.serie, parsed.folio,
],
);
console.log(` ✅ actualizada fila id=${row.id}`);
ok++;
} catch (e: any) {
console.log(` ❌ error: ${e?.message || String(e)}`);
fail++;
}
}
console.log(`\n=== Resumen: ${ok} actualizadas, ${fail} fallidas ===`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,174 @@
/**
* Backfill de `fecha_emision` (y opcionalmente `fecha_cert_sat`) para CFDIs
* sincronizados antes del fix de zona horaria. El parser convertía la fecha
* del XML ("2025-12-31T18:37:51") asumiéndola como hora local de la máquina
* y la guardaba en UTC ("2026-01-01T00:37:51Z"), corriendo 6 horas y a veces
* sacando el CFDI de su mes/año correcto.
*
* Re-parsea la fecha literal del XML (atributo `Fecha=""` del Comprobante y
* `FechaTimbrado=""` del TimbreFiscalDigital) y lo guarda como UTC-literal
* (forzando 'Z' al string del XML).
*
* Solo aplica a CFDIs con `xml_original IS NOT NULL`. Idempotente.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/backfill-fechas-tz.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/backfill-fechas-tz.ts --dry # reporta
*/
import { prisma, tenantDb } from '../src/config/database.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
function parseLiteral(str: string | null | undefined): Date | null {
if (!str) return null;
const s = String(str).trim();
if (!s) return null;
const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s);
return new Date(hasTz ? s : s + 'Z');
}
function extractFechaFromXml(xml: string): string | null {
// Atributo Fecha del root <cfdi:Comprobante Fecha="...">
const m = xml.match(/<cfdi:Comprobante\b[^>]*\bFecha="([^"]+)"/);
return m ? m[1] : null;
}
function extractFechaTimbradoFromXml(xml: string): string | null {
const m = xml.match(/<tfd:TimbreFiscalDigital\b[^>]*\bFechaTimbrado="([^"]+)"/);
return m ? m[1] : null;
}
interface PerTenantResult {
tenantId: string;
rfc: string;
databaseName: string;
scanned: number;
updatedFechaEmision: number;
updatedFechaCert: number;
noChange: number;
noXmlMatch: number;
error?: string;
}
async function backfillTenant(tenantId: string, rfc: string, databaseName: string): Promise<PerTenantResult> {
const result: PerTenantResult = {
tenantId, rfc, databaseName,
scanned: 0, updatedFechaEmision: 0, updatedFechaCert: 0, noChange: 0, noXmlMatch: 0,
};
const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows } = await pool.query<{
id: number;
uuid: string;
fecha_emision: Date;
fecha_cert_sat: Date | null;
xml_original: string;
}>(
`SELECT id, uuid, fecha_emision, fecha_cert_sat, xml_original
FROM cfdis
WHERE xml_original IS NOT NULL
ORDER BY id`,
);
result.scanned = rows.length;
if (rows.length === 0) return result;
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const row of rows) {
const fechaXml = extractFechaFromXml(row.xml_original);
const fechaTimbradoXml = extractFechaTimbradoFromXml(row.xml_original);
if (!fechaXml) {
result.noXmlMatch++;
continue;
}
const nuevaFecha = parseLiteral(fechaXml);
const nuevaFechaCert = fechaTimbradoXml ? parseLiteral(fechaTimbradoXml) : null;
if (!nuevaFecha) {
result.noXmlMatch++;
continue;
}
const fechaEmisionActual = row.fecha_emision?.toISOString();
const fechaCertActual = row.fecha_cert_sat?.toISOString();
const fechaEmisionNueva = nuevaFecha.toISOString();
const fechaCertNueva = nuevaFechaCert?.toISOString();
let updatedThis = false;
if (fechaEmisionActual !== fechaEmisionNueva) {
await client.query(
`UPDATE cfdis SET fecha_emision = $2 WHERE id = $1`,
[row.id, nuevaFecha],
);
result.updatedFechaEmision++;
updatedThis = true;
}
if (nuevaFechaCert && fechaCertActual !== fechaCertNueva) {
await client.query(
`UPDATE cfdis SET fecha_cert_sat = $2 WHERE id = $1`,
[row.id, nuevaFechaCert],
);
result.updatedFechaCert++;
updatedThis = true;
}
if (!updatedThis) result.noChange++;
}
if (DRY_RUN) await client.query('ROLLBACK');
else await client.query('COMMIT');
} catch (err: any) {
await client.query('ROLLBACK').catch(() => {});
result.error = err?.message || String(err);
} finally {
client.release();
}
return result;
}
async function main() {
console.log(`=== Backfill fechas (fecha_emision + fecha_cert_sat) ${DRY_RUN ? '(DRY RUN)' : ''} ===\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
const results: PerTenantResult[] = [];
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] ... `);
try {
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
results.push(r);
if (r.error) console.log(`ERROR: ${r.error}`);
else if (r.scanned === 0) console.log(`sin XMLs (skip)`);
else console.log(
`scan=${r.scanned} upd_emision=${r.updatedFechaEmision} upd_cert=${r.updatedFechaCert} ` +
`sin_cambio=${r.noChange} sin_match=${r.noXmlMatch}${DRY_RUN ? ' (rolled back)' : ''}`,
);
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
}
}
const totalScan = results.reduce((s, r) => s + r.scanned, 0);
const totalUpdEm = results.reduce((s, r) => s + r.updatedFechaEmision, 0);
const totalUpdCert = results.reduce((s, r) => s + r.updatedFechaCert, 0);
const tFail = results.filter(r => r.error).length;
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${results.length}`);
console.log(` CFDIs escaneados: ${totalScan}`);
console.log(` fecha_emision actualizada: ${totalUpdEm}`);
console.log(` fecha_cert_sat actualizada: ${totalUpdCert}`);
if (tFail > 0) console.log(` Tenants con error: ${tFail}`);
await prisma.$disconnect();
process.exit(tFail > 0 ? 1 : 0);
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,101 @@
/**
* Backfill de métricas mensuales pre-calculadas (Tanda A hot/cold).
*
* Itera todos los tenants activos, sus contribuyentes, y popula la tabla
* `metricas_mensuales` con los agregados de años pasados (desde el CFDI más
* antiguo hasta el año actual - 1). El año actual queda on-the-fly.
*
* Idempotente: usa upsert — re-correrlo no duplica filas, recalcula valores.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/backfill-metricas.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/backfill-metricas.ts --dry # dry-run
*
* Opciones via env:
* BACKFILL_DESDE_ANIO=2023 # limita el rango inferior
* BACKFILL_HASTA_ANIO=2024 # default: año actual - 1
* BACKFILL_TENANT=<uuid> # procesa solo un tenant
*/
import { prisma } from '../src/config/database.js';
import { backfillTenant } from '../src/services/metricas-compute.service.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
const TENANT_FILTER = process.env.BACKFILL_TENANT || null;
const DESDE_ANIO = process.env.BACKFILL_DESDE_ANIO ? parseInt(process.env.BACKFILL_DESDE_ANIO, 10) : undefined;
const HASTA_ANIO = process.env.BACKFILL_HASTA_ANIO ? parseInt(process.env.BACKFILL_HASTA_ANIO, 10) : undefined;
async function main() {
console.log(`=== Backfill metricas_mensuales ${DRY_RUN ? '(DRY RUN)' : ''} ===\n`);
if (DESDE_ANIO) console.log(`Desde año: ${DESDE_ANIO}`);
if (HASTA_ANIO) console.log(`Hasta año: ${HASTA_ANIO}`);
if (TENANT_FILTER) console.log(`Tenant filtro: ${TENANT_FILTER}`);
console.log();
const tenants = await prisma.tenant.findMany({
where: {
active: true,
...(TENANT_FILTER ? { id: TENANT_FILTER } : {}),
},
select: { id: true, rfc: true, nombre: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
let totalContribs = 0;
let totalMeses = 0;
let totalFilas = 0;
let totalErrores = 0;
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] ${t.nombre} ... `);
try {
const r = await backfillTenant(t.id, {
dryRun: DRY_RUN,
desdeAnio: DESDE_ANIO,
hastaAnio: HASTA_ANIO,
});
if (r.contribuyentesProcesados === 0) {
console.log('sin contribuyentes (skip)');
} else {
console.log(
`${r.contribuyentesProcesados} contribs, ${r.mesesProcesados} meses, ` +
`${r.filasEscritas} filas${r.errores.length > 0 ? `, ${r.errores.length} errores` : ''}`,
);
if (r.errores.length > 0 && r.errores.length <= 5) {
for (const e of r.errores) {
console.log(` ERR (${e.anio}-${String(e.mes).padStart(2, '0')}): ${e.error}`);
}
} else if (r.errores.length > 5) {
console.log(` (${r.errores.length} errores — los primeros 3):`);
for (const e of r.errores.slice(0, 3)) {
console.log(` ERR (${e.anio}-${String(e.mes).padStart(2, '0')}): ${e.error}`);
}
}
}
totalContribs += r.contribuyentesProcesados;
totalMeses += r.mesesProcesados;
totalFilas += r.filasEscritas;
totalErrores += r.errores.length;
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
totalErrores++;
}
}
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${tenants.length}`);
console.log(` Contribuyentes: ${totalContribs}`);
console.log(` (Contribuyente, mes): ${totalMeses}`);
console.log(` Filas metricas_mensuales: ${totalFilas}${DRY_RUN ? ' (NO escritas)' : ''}`);
if (totalErrores > 0) console.log(` Errores: ${totalErrores}`);
await prisma.$disconnect();
process.exit(totalErrores > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,163 @@
/**
* Backfill de `saldo_pendiente_mxn` para CFDIs I PPD vigentes. Computa el
* saldo con la fórmula centralizada en `utils/saldo.ts` (pagos P + NC no-07
* + anticipo aplicado si es I/07) y lo persiste.
*
* Idempotente: corrido varias veces produce el mismo resultado. Safe para
* repetir después de un sync SAT masivo o si se sospecha drift.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/backfill-saldo-pendiente.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/backfill-saldo-pendiente.ts --dry # reporta sin escribir
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { saldoComputadoExpr } from '../src/utils/saldo.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
interface PerTenantResult {
tenantId: string;
rfc: string;
databaseName: string;
iPpdsVigentes: number;
actualizadas: number;
saldoTotalAntes: number;
saldoTotalDespues: number;
error?: string;
}
async function backfillTenant(
tenantId: string,
rfc: string,
databaseName: string,
): Promise<PerTenantResult> {
const result: PerTenantResult = {
tenantId,
rfc,
databaseName,
iPpdsVigentes: 0,
actualizadas: 0,
saldoTotalAntes: 0,
saldoTotalDespues: 0,
};
const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows: count } = await pool.query<{ n: number; suma: string }>(
`SELECT COUNT(*)::int AS n, COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) AS suma
FROM cfdis
WHERE tipo_comprobante = 'I' AND metodo_pago = 'PPD'
AND status NOT IN ('Cancelado', '0')`,
);
result.iPpdsVigentes = count[0]?.n || 0;
result.saldoTotalAntes = Number(count[0]?.suma || 0);
if (result.iPpdsVigentes === 0) return result;
const client = await pool.connect();
try {
await client.query('BEGIN');
// UPDATE masivo con la fórmula centralizada (misma que hooks y reporte).
const expr = saldoComputadoExpr('c');
const { rowCount } = await client.query(
`UPDATE cfdis c
SET saldo_pendiente_mxn = ${expr}
WHERE c.tipo_comprobante = 'I'
AND c.metodo_pago = 'PPD'
AND c.status NOT IN ('Cancelado', '0')`,
);
result.actualizadas = rowCount ?? 0;
const { rows: cntDespues } = await client.query<{ suma: string }>(
`SELECT COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) AS suma
FROM cfdis
WHERE tipo_comprobante = 'I' AND metodo_pago = 'PPD'
AND status NOT IN ('Cancelado', '0')`,
);
result.saldoTotalDespues = Number(cntDespues[0]?.suma || 0);
if (DRY_RUN) {
await client.query('ROLLBACK');
} else {
await client.query('COMMIT');
}
} catch (err: any) {
await client.query('ROLLBACK').catch(() => {});
result.error = err?.message || String(err);
} finally {
client.release();
}
return result;
}
function fmt(n: number): string {
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
async function main() {
console.log(`=== Backfill saldo_pendiente_mxn ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
const results: PerTenantResult[] = [];
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] ... `);
try {
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
results.push(r);
if (r.error) {
console.log(`ERROR: ${r.error}`);
} else if (r.iPpdsVigentes === 0) {
console.log(`sin I PPD vigentes (skip)`);
} else {
const delta = r.saldoTotalDespues - r.saldoTotalAntes;
console.log(
`I_PPD=${r.iPpdsVigentes} upd=${r.actualizadas} ` +
`antes=${fmt(r.saldoTotalAntes)} despues=${fmt(r.saldoTotalDespues)} ` +
`Δ=${delta >= 0 ? '+' : ''}${fmt(delta)}${DRY_RUN ? ' (rolled back)' : ''}`,
);
}
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
results.push({
tenantId: t.id,
rfc: t.rfc,
databaseName: t.databaseName,
iPpdsVigentes: 0,
actualizadas: 0,
saldoTotalAntes: 0,
saldoTotalDespues: 0,
error: err?.message || String(err),
});
}
}
const totalI = results.reduce((s, r) => s + r.iPpdsVigentes, 0);
const totalAntes = results.reduce((s, r) => s + r.saldoTotalAntes, 0);
const totalDespues = results.reduce((s, r) => s + r.saldoTotalDespues, 0);
const tenantsFailed = results.filter(r => r.error).length;
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${results.length}`);
console.log(` I PPD vigentes total: ${totalI}`);
console.log(` Saldo total antes: ${fmt(totalAntes)}`);
console.log(` Saldo total después: ${fmt(totalDespues)}${DRY_RUN ? ' (rolled back)' : ''}`);
console.log(` Delta (recuperado): ${fmt(totalAntes - totalDespues)} (saldo que ya no está pendiente)`);
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
await prisma.$disconnect();
process.exit(tenantsFailed > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,135 @@
/**
* Bootstrap del tenant admin global (Horux 360 — HTS240708LJA) + usuarios staff.
*
* Crea:
* 1. Tenant Horux 360 (RFC HTS240708LJA, plan enterprise)
* 2. Carlos como owner del tenant + rol platform_admin
* 3. Ivan como contador del tenant + rol platform_ti (TI superset)
* 4. Suscripción authorized por 1 año
*
* Uso: `pnpm bootstrap:admin-global`
*
* Idempotente-ish: falla limpio si el tenant ya existe (RFC unique).
* Para re-ejecutar, borra el tenant y su BD manualmente antes.
*
* Requisitos previos:
* 1. `pnpm prisma migrate deploy` (schema central)
* 2. `pnpm db:seed` (catálogos SAT, regímenes, ISR, eventos fiscales, roles)
*
* Env vars opcionales (con defaults):
* HORUX_ADMIN_EMAIL (default: carlos@horuxfin.com)
* HORUX_ADMIN_NOMBRE (default: Carlos)
* HORUX_TI_EMAIL (default: ivan@horuxfin.com)
* HORUX_TI_NOMBRE (default: Ivan)
*/
import { prisma } from '../src/config/database.js';
import * as tenantsService from '../src/services/tenants.service.js';
import * as usuariosService from '../src/services/usuarios.service.js';
const RFC = 'HTS240708LJA';
const TENANT_NAME = 'Horux 360';
const PLAN = 'enterprise' as const;
const CFDI_LIMIT = -1; // ilimitado
const USERS_LIMIT = 10;
const SUBSCRIPTION_YEARS = 1;
async function main() {
const adminEmail = process.env.HORUX_ADMIN_EMAIL || 'carlos@horuxfin.com';
const adminNombre = process.env.HORUX_ADMIN_NOMBRE || 'Carlos';
const tiEmail = process.env.HORUX_TI_EMAIL || 'ivan@horuxfin.com';
const tiNombre = process.env.HORUX_TI_NOMBRE || 'Ivan';
console.log(`Bootstrap del tenant admin global`);
console.log(` RFC: ${RFC}`);
console.log(` Nombre: ${TENANT_NAME}`);
console.log(` Admin: ${adminNombre} <${adminEmail}> (platform_admin)`);
console.log(` TI: ${tiNombre} <${tiEmail}> (platform_ti)`);
console.log(` Plan: ${PLAN} (cfdi: ${CFDI_LIMIT}, users: ${USERS_LIMIT})`);
console.log('');
// 1. Crea tenant + BD provisionada + Carlos como owner + subscription pending
const { tenant, user: carlosUser, tempPassword: carlosPassword } = await tenantsService.createTenant({
nombre: TENANT_NAME,
rfc: RFC,
plan: PLAN,
cfdiLimit: CFDI_LIMIT,
usersLimit: USERS_LIMIT,
adminEmail,
adminNombre,
amount: 0,
});
console.log(`✓ Tenant creado: ${tenant.id}`);
console.log(`✓ BD provisionada: ${tenant.databaseName}`);
console.log(`✓ Carlos creado (owner): ${carlosUser.email}`);
// 2. Asigna platform_admin a Carlos (no se hace automáticamente desde tenants.service)
const carlosFull = await prisma.user.findUnique({ where: { email: adminEmail } });
if (carlosFull) {
await prisma.userPlatformRole.upsert({
where: { userId_role: { userId: carlosFull.id, role: 'platform_admin' } },
update: {},
create: { userId: carlosFull.id, role: 'platform_admin' },
});
console.log(`✓ Carlos: rol platform_admin asignado`);
}
// 3. Crea Ivan como contador del tenant (membership) y le asigna platform_ti
const ivan = await usuariosService.inviteUsuario(tenant.id, {
email: tiEmail,
nombre: tiNombre,
role: 'contador',
});
console.log(`✓ Ivan creado: ${ivan.email} (membership contador)`);
await prisma.userPlatformRole.upsert({
where: { userId_role: { userId: ivan.id, role: 'platform_ti' } },
update: {},
create: { userId: ivan.id, role: 'platform_ti' },
});
console.log(`✓ Ivan: rol platform_ti asignado (superset, mismos permisos que admin)`);
// 4. Sube la subscription a 'authorized' con vigencia de 1 año
const existing = await prisma.subscription.findFirst({
where: { tenantId: tenant.id },
orderBy: { createdAt: 'desc' },
});
if (existing) {
const now = new Date();
const end = new Date(now);
end.setFullYear(end.getFullYear() + SUBSCRIPTION_YEARS);
await prisma.subscription.update({
where: { id: existing.id },
data: {
status: 'authorized',
currentPeriodStart: now,
currentPeriodEnd: end,
},
});
console.log(`✓ Suscripción marcada 'authorized' hasta ${end.toISOString().slice(0, 10)}`);
}
console.log('');
console.log('=== DONE ===');
console.log(`Credenciales temporales para primer login:`);
console.log(` Carlos (admin): ${adminEmail}`);
console.log(` Password: ${carlosPassword}`);
console.log('');
console.log(` Ivan (TI): ${tiEmail}`);
console.log(` Password: revisa el correo de bienvenida (inviteUsuario lo envía por email)`);
console.log('');
console.log('Próximos pasos manuales:');
console.log(` 1. Carlos login en /login con las credenciales de arriba`);
console.log(` 2. Cambiar el password desde /configuracion/seguridad`);
console.log(` 3. Verificar que Ivan recibió su correo de invitación`);
console.log(` 4. Subir FIEL en /configuracion/sat para habilitar sincronización`);
console.log(` 5. (Opcional) Configurar organización Facturapi en /configuracion`);
}
main()
.catch((err) => {
console.error('✗ Bootstrap falló:', err.message || err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,75 @@
import { prisma, tenantDb } from '../src/config/database.js';
const yearMonth = '2025-02';
const contribuyenteId = 'd745a915-6a23-4818-944b-a7e1e18e536a';
const tenantRfc = 'DESPACHO_MO3NI6U8_B9VGG';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
// Drill desglosado por régimen del receptor
const { rows } = await pool.query(
`SELECT
COALESCE(regimen_fiscal_receptor, 'null') AS regimen_rec,
type, tipo_comprobante, metodo_pago,
COALESCE(cfdi_tipo_relacion, '') AS tipo_rel,
COUNT(*)::int AS n,
SUM(total_mxn) AS total_bruto,
SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})) AS total_neto,
SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})) AS pago_neto
FROM cfdis
WHERE (
(type='RECIBIDO' AND tipo_comprobante='I' AND metodo_pago='PUE')
OR (type='RECIBIDO' AND tipo_comprobante='P')
OR (type='RECIBIDO' AND tipo_comprobante='E' AND metodo_pago='PUE' AND COALESCE(cfdi_tipo_relacion,'')<>'07')
)
AND status NOT IN ('Cancelado','0')
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
OR (tipo_comprobante!='P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
AND contribuyente_id = $3
GROUP BY regimen_rec, type, tipo_comprobante, metodo_pago, tipo_rel
ORDER BY regimen_rec, tipo_comprobante, metodo_pago`,
[fi, ff, contribuyenteId],
);
const byReg: Record<string, { fact: number; pago: number; nc: number; detalle: any[] }> = {};
for (const r of rows) {
const reg = r.regimen_rec;
if (!byReg[reg]) byReg[reg] = { fact: 0, pago: 0, nc: 0, detalle: [] };
const v = r.tipo_comprobante === 'P' ? Number(r.pago_neto) : Number(r.total_neto);
byReg[reg].detalle.push({ tc: r.tipo_comprobante, mp: r.metodo_pago, rel: r.tipo_rel, n: r.n, valor: v, bruto: Number(r.total_bruto) });
if (r.tipo_comprobante === 'I') byReg[reg].fact += v;
else if (r.tipo_comprobante === 'P') byReg[reg].pago += v;
else if (r.tipo_comprobante === 'E') byReg[reg].nc += v;
}
console.log(`\n=== DRILL-DOWN por régimen del receptor — ${fi} a ${ff} ===\n`);
let totalAll = 0;
const TODOS_REGS = new Set(['605','606','612','621','625','626','601','603','607','608','610','611','614','615','620','622','623','624']);
for (const [reg, v] of Object.entries(byReg).sort()) {
const subtot = v.fact + v.pago - v.nc;
totalAll += subtot;
const inTodos = TODOS_REGS.has(reg) ? '✓' : '✗ (excluido de TODOS_REGIMENES)';
console.log(`Régimen ${reg} ${inTodos}`);
console.log(` fact=${v.fact.toFixed(2)} pago=${v.pago.toFixed(2)} NC=${v.nc.toFixed(2)} → subtotal=${subtot.toFixed(2)}`);
for (const d of v.detalle) {
console.log(` ${d.tc} ${d.mp || '-'} rel=${d.rel || '-'} n=${d.n} bruto=${d.bruto.toFixed(2)} neto=${d.valor.toFixed(2)}`);
}
}
console.log(`\nTotal todos regímenes: ${totalAll.toFixed(2)}`);
const inTodos = Object.entries(byReg).filter(([r]) => TODOS_REGS.has(r)).reduce((s, [, v]) => s + (v.fact + v.pago - v.nc), 0);
console.log(`Total solo en TODOS_REGIMENES: ${inTodos.toFixed(2)}`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,67 @@
/**
* Breakdown ingresos por grupo + filas que el drill-down mostraría,
* para un contribuyente + mes. Identifica discrepancias entre el
* dashboard y el drill.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
const yearMonth = process.argv[4] || '2025-05';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
console.log(`\n=== ${yearMonth} ${contribuyenteId} RFC=${ctx.rfc} ===\n`);
console.log(`esEmisor: ${ctx.esEmisor}`);
console.log(`esReceptor: ${ctx.esReceptor}\n`);
// Todos los CFDIs donde el contribuyente es emisor en el mes (ingresos potenciales)
const { rows: emitidos } = await pool.query(
`SELECT uuid, fecha_emision, tipo_comprobante, metodo_pago,
cfdi_tipo_relacion, regimen_fiscal_emisor, regimen_fiscal_receptor,
total_mxn, monto_pago_mxn
FROM cfdis
WHERE ${ctx.esEmisor}
AND status NOT IN ('Cancelado', '0')
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
OR (tipo_comprobante<>'P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
ORDER BY fecha_emision, uuid`,
[fi, ff],
);
console.log(`EMITIDOS por el contribuyente en el mes: ${emitidos.length}`);
let sumaTotal = 0, sumaPagos = 0;
const porRegimen: Record<string, { n: number; total: number; pago: number; types: Record<string, number> }> = {};
for (const r of emitidos) {
const reg = r.regimen_fiscal_emisor || 'NULL';
const tcKey = `${r.tipo_comprobante}${r.metodo_pago ? '/' + r.metodo_pago : ''}${r.cfdi_tipo_relacion ? '/rel=' + r.cfdi_tipo_relacion : ''}`;
if (!porRegimen[reg]) porRegimen[reg] = { n: 0, total: 0, pago: 0, types: {} };
porRegimen[reg].n++;
porRegimen[reg].total += Number(r.total_mxn || 0);
porRegimen[reg].pago += Number(r.monto_pago_mxn || 0);
porRegimen[reg].types[tcKey] = (porRegimen[reg].types[tcKey] || 0) + 1;
sumaTotal += Number(r.total_mxn || 0);
sumaPagos += Number(r.monto_pago_mxn || 0);
}
console.log(`Suma total_mxn: ${sumaTotal.toFixed(2)} | Suma monto_pago_mxn: ${sumaPagos.toFixed(2)}\n`);
for (const [reg, v] of Object.entries(porRegimen)) {
console.log(` Régimen ${reg}: n=${v.n} total=${v.total.toFixed(2)} pago=${v.pago.toFixed(2)}`);
for (const [tc, n] of Object.entries(v.types)) {
console.log(` ${tc}: ${n}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,24 @@
import { prisma, tenantDb } from '../src/config/database.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3];
const year = process.argv[4] || '2025';
const month = process.argv[5];
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const monthFilter = month ? `AND mes = ${Number(month)}` : '';
const { rows } = await pool.query(
`SELECT anio, mes, regimen_fiscal, ingresos_cobrados, egresos_pagados,
iva_trasladado_total, iva_acreditable, computed_at
FROM metricas_mensuales
WHERE contribuyente_id = $1 AND anio = $2 ${monthFilter}
ORDER BY mes, regimen_fiscal`,
[contribuyenteId, Number(year)],
);
for (const r of rows) console.log(r);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,26 @@
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows } = await pool.query(
`SELECT anio, mes, regimen_fiscal,
ingresos_cobrados, egresos_pagados,
iva_trasladado_total, iva_acreditable,
computed_at
FROM metricas_mensuales
WHERE contribuyente_id = $1 AND anio = 2025 AND mes = 2
ORDER BY regimen_fiscal`,
['d745a915-6a23-4818-944b-a7e1e18e536a'],
);
console.log(`Cache rows para Feb 2025:`);
for (const r of rows) console.log(r);
// Also force on-the-fly by setting BYPASS
process.env.METRICAS_BYPASS_CACHE = '1';
console.log(`\n(cache bypassed below is N/A here; the dashboard service reads planCache directly)`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,85 @@
import { prisma, tenantDb } from '../src/config/database.js';
const RFC_CARLOS = 'TORC9611214CA';
async function main() {
const tenants = await prisma.tenant.findMany({
select: { id: true, rfc: true, databaseName: true },
});
let found = false;
for (const t of tenants) {
let pool;
try {
pool = await tenantDb.getPool(t.id, t.databaseName);
} catch {
continue;
}
const { rows: contribs } = await pool.query(
`SELECT c.entidad_id, c.rfc, c.regimen_fiscal, e.nombre, fo.facturapi_org_id, fo.csd_uploaded, fo.active AS org_active
FROM contribuyentes c
JOIN entidades_gestionadas e ON e.id = c.entidad_id
LEFT JOIN facturapi_orgs fo ON fo.contribuyente_id = c.entidad_id
WHERE UPPER(c.rfc) = $1`,
[RFC_CARLOS],
);
if (contribs.length === 0) continue;
found = true;
console.log(`\n=== Tenant ${t.rfc} — BD ${t.databaseName} ===`);
for (const c of contribs) {
console.log(`Contribuyente Carlos: ${c.entidad_id}`);
console.log(` nombre=${c.nombre}`);
console.log(` regimen_fiscal (CSV)=${c.regimen_fiscal}`);
console.log(` facturapi_org_id=${c.facturapi_org_id || 'NULL (sin org)'}`);
console.log(` csd_uploaded=${c.csd_uploaded} org_active=${c.org_active}`);
}
const { rows: cfdis } = await pool.query(
`SELECT uuid, type, tipo_comprobante, metodo_pago, total, total_mxn,
rfc_emisor, rfc_receptor, nombre_receptor, status, fecha_emision,
source, facturapi_id
FROM cfdis
WHERE UPPER(rfc_emisor) = $1
AND (source = 'facturapi' OR facturapi_id IS NOT NULL OR fecha_emision >= NOW() - interval '2 days')
ORDER BY fecha_emision DESC
LIMIT 10`,
[RFC_CARLOS],
);
console.log(`\nÚltimas ${cfdis.length} facturas (facturapi o recientes) emitidas por ${RFC_CARLOS}:`);
for (const c of cfdis) {
console.log(` UUID=${c.uuid}`);
console.log(` tipo=${c.tipo_comprobante} mp=${c.metodo_pago} status=${c.status} source=${c.source}`);
console.log(` receptor=${c.rfc_receptor} (${c.nombre_receptor})`);
console.log(` total=${c.total} total_mxn=${c.total_mxn}`);
console.log(` fecha_emision=${c.fecha_emision?.toISOString?.() || c.fecha_emision}`);
console.log(` facturapi_id=${c.facturapi_id}`);
}
const { rows: [anyEmitido] } = await pool.query(
`SELECT COUNT(*)::int AS total,
SUM(CASE WHEN source='facturapi' THEN 1 ELSE 0 END)::int AS via_facturapi,
SUM(CASE WHEN source='facturapi' AND status NOT IN ('Cancelado','0') THEN 1 ELSE 0 END)::int AS vigentes
FROM cfdis
WHERE UPPER(rfc_emisor) = $1`,
[RFC_CARLOS],
);
console.log(`\nResumen total CFDIs con rfc_emisor=${RFC_CARLOS}:`);
console.log(` total=${anyEmitido.total} via_facturapi=${anyEmitido.via_facturapi} vigentes_facturapi=${anyEmitido.vigentes}`);
}
if (!found) {
console.log(`\nNo se encontró contribuyente con RFC ${RFC_CARLOS} en ningún tenant.`);
}
await prisma.$disconnect();
}
main().catch(async e => {
console.error(e);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,72 @@
import { prisma, tenantDb } from '../src/config/database.js';
import { env } from '../src/config/env.js';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
// 1. Last CSF stored for Carlos (source of truth on what SAT sees)
const { rows: csfs } = await pool.query(
`SELECT rfc, created_at, datos->'regimenes' AS regimenes, datos->'obligaciones' AS obligaciones,
datos->>'estatusPadron' AS estatus, datos->>'fechaInicioOperaciones' AS fecha_inicio,
datos->'domicilio' AS domicilio
FROM constancias_situacion_fiscal
WHERE UPPER(rfc) = 'TORC9611214CA'
ORDER BY created_at DESC LIMIT 1`,
);
console.log(`\n=== CSF más reciente de Carlos ===`);
if (csfs.length === 0) {
console.log('NO HAY CSF descargada para este RFC. Eso explica el error de LCO si el contribuyente no ha sincronizado con SAT.');
} else {
const c = csfs[0];
console.log(`created_at: ${c.created_at}`);
console.log(`estatusPadron: ${c.estatus}`);
console.log(`fechaInicioOper: ${c.fecha_inicio}`);
console.log(`Regímenes (CSF):`);
if (Array.isArray(c.regimenes)) for (const r of c.regimenes) console.log(' ', r);
console.log(`Obligaciones (CSF):`);
if (Array.isArray(c.obligaciones)) for (const o of c.obligaciones) console.log(' ', o);
}
// 2. Contribuyente data en BD (lo que estamos usando para llenar la org)
const { rows: contrib } = await pool.query(
`SELECT c.entidad_id, c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
FROM contribuyentes c
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE UPPER(c.rfc) = 'TORC9611214CA'`,
);
console.log(`\n=== Contribuyente en BD ===`);
console.log(contrib[0]);
// 3. Facturapi org actual (lo que Facturapi está enviando al SAT)
const { rows: org } = await pool.query(
`SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`,
[contrib[0]?.entidad_id],
);
if (org.length > 0 && env.FACTURAPI_USER_KEY) {
const res = await fetch(`https://www.facturapi.io/v2/organizations/${org[0].facturapi_org_id}`, {
headers: { 'Authorization': `Bearer ${env.FACTURAPI_USER_KEY}` },
});
if (res.ok) {
const o = await res.json() as any;
console.log(`\n=== Facturapi Organization ===`);
console.log(`orgId: ${o.id}`);
console.log(`name: ${o.name}`);
console.log(`legal:`);
console.log(` legal_name: ${o.legal?.legal_name}`);
console.log(` tax_system: ${o.legal?.tax_system}`);
console.log(` name: ${o.legal?.name}`);
console.log(` address: ${JSON.stringify(o.legal?.address)}`);
console.log(`certificate:`);
console.log(` has_certificate: ${o.certificate?.has_certificate}`);
console.log(` serial_number: ${o.certificate?.serial_number}`);
console.log(` valid_until: ${o.certificate?.valid_until}`);
} else {
console.log(`Facturapi GET failed: ${res.status}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,112 @@
/**
* Detecta complementos P cuya ieps_traslado_pago_mxn parece inflada
* respecto al monto pagado y respecto a la factura referenciada.
*
* Heurísticas:
* 1. IEPS del P > monto_pago × 1.6 (tasa máxima teórica SAT para bebidas
* con alto contenido alcohólico; cualquier cosa arriba es sospechoso).
* 2. IEPS del P > IEPS de la factura original a la que se refiere
* (imposible — un pago parcial no puede transferir más IEPS que el total).
* 3. Ratio IEPS / monto_pago vs IEPS_original / total_original, donde la
* proporción del P excede la del original por >5pp (señal de error
* del proveedor).
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenants = await prisma.tenant.findMany({
select: { id: true, rfc: true, databaseName: true },
});
for (const t of tenants) {
let pool;
try {
pool = await tenantDb.getPool(t.id, t.databaseName);
} catch {
continue;
}
console.log(`\n=== Tenant ${t.rfc} (${t.databaseName}) ===`);
// Heurística 1: IEPS > 160% del monto
const { rows: h1 } = await pool.query(`
SELECT uuid, rfc_emisor, rfc_receptor, monto_pago_mxn, ieps_traslado_pago_mxn,
(ieps_traslado_pago_mxn / NULLIF(monto_pago_mxn, 0))::numeric(10,4) AS ratio
FROM cfdis
WHERE tipo_comprobante = 'P'
AND status NOT IN ('Cancelado', '0')
AND COALESCE(ieps_traslado_pago_mxn, 0) > 0
AND COALESCE(monto_pago_mxn, 0) > 0
AND ieps_traslado_pago_mxn > monto_pago_mxn * 1.6
ORDER BY ieps_traslado_pago_mxn DESC
LIMIT 10
`);
console.log(`\n-- H1: IEPS > monto_pago × 1.6 (${h1.length}) --`);
for (const r of h1) {
console.log(` ${r.uuid.substring(0, 8)} ${r.rfc_emisor}${r.rfc_receptor} pago=${Number(r.monto_pago_mxn).toFixed(2)} IEPS=${Number(r.ieps_traslado_pago_mxn).toFixed(2)} ratio=${r.ratio}`);
}
// Heurística 2: IEPS del P > IEPS de la factura referenciada (imposible)
// uuid_relacionado es pipe-separated; normalizar
const { rows: h2 } = await pool.query(`
SELECT p.uuid AS p_uuid, p.rfc_emisor, p.monto_pago_mxn, p.ieps_traslado_pago_mxn,
i.uuid AS i_uuid, i.total_mxn AS i_total, i.ieps_traslado_mxn AS i_ieps
FROM cfdis p
JOIN cfdis i
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(COALESCE(p.uuid_relacionado, '')), '|'))
AND i.status NOT IN ('Cancelado', '0')
WHERE p.tipo_comprobante = 'P'
AND p.status NOT IN ('Cancelado', '0')
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > 0
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > COALESCE(i.ieps_traslado_mxn, 0)
ORDER BY p.ieps_traslado_pago_mxn DESC
LIMIT 10
`);
console.log(`\n-- H2: IEPS del P > IEPS de la factura referenciada (${h2.length}) --`);
for (const r of h2) {
const ratio = r.i_ieps > 0 ? Number(r.ieps_traslado_pago_mxn) / Number(r.i_ieps) : 0;
console.log(` P=${r.p_uuid.substring(0, 8)} IEPS_P=${Number(r.ieps_traslado_pago_mxn).toFixed(2)} I=${r.i_uuid.substring(0, 8)} IEPS_I=${Number(r.i_ieps || 0).toFixed(2)} ratio=${ratio.toFixed(2)}x`);
}
// Heurística 3: ratio IEPS/pago del P muy distinto del ratio IEPS/total del I
const { rows: h3 } = await pool.query(`
SELECT p.uuid AS p_uuid, p.monto_pago_mxn, p.ieps_traslado_pago_mxn,
i.uuid AS i_uuid, i.total_mxn AS i_total, i.ieps_traslado_mxn AS i_ieps,
(p.ieps_traslado_pago_mxn / NULLIF(p.monto_pago_mxn, 0))::numeric(6,4) AS ratio_p,
(i.ieps_traslado_mxn / NULLIF(i.total_mxn, 0))::numeric(6,4) AS ratio_i
FROM cfdis p
JOIN cfdis i
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(COALESCE(p.uuid_relacionado, '')), '|'))
AND i.status NOT IN ('Cancelado', '0')
WHERE p.tipo_comprobante = 'P'
AND p.status NOT IN ('Cancelado', '0')
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > 0
AND COALESCE(i.ieps_traslado_mxn, 0) > 0
AND COALESCE(p.monto_pago_mxn, 0) > 0
AND COALESCE(i.total_mxn, 0) > 0
AND ABS(
(p.ieps_traslado_pago_mxn / p.monto_pago_mxn)
- (i.ieps_traslado_mxn / i.total_mxn)
) > 0.05
ORDER BY p.ieps_traslado_pago_mxn DESC
LIMIT 10
`);
console.log(`\n-- H3: ratio_P ratio_I > 5pp (${h3.length}) --`);
for (const r of h3) {
console.log(` P=${r.p_uuid.substring(0, 8)} ratio_P=${r.ratio_p} I=${r.i_uuid.substring(0, 8)} ratio_I=${r.ratio_i} delta=${(Number(r.ratio_p) - Number(r.ratio_i)).toFixed(4)}`);
}
// Resumen: total de P con IEPS > 0
const { rows: [summary] } = await pool.query(`
SELECT COUNT(*) FILTER (WHERE COALESCE(ieps_traslado_pago_mxn, 0) > 0)::int AS p_con_ieps,
COUNT(*) FILTER (WHERE tipo_comprobante = 'P')::int AS p_total
FROM cfdis
WHERE status NOT IN ('Cancelado', '0')
`);
console.log(`\nResumen: ${summary.p_con_ieps} P con IEPS > 0 (de ${summary.p_total} P totales)`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,76 @@
import { prisma, tenantDb } from '../src/config/database.js';
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC },
select: { id: true, databaseName: true },
});
if (!tenant) {
console.log('Tenant no encontrado');
return;
}
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
console.log(`\n=== Tenant ${TENANT_RFC} ===\n`);
// 1) CFDIs emitidos via Facturapi (cualquier emisor) últimos 7 días
console.log(`>> CFDIs con source='facturapi' o facturapi_id no nulo, últimos 7 días:`);
const { rows: recientes } = await pool.query(
`SELECT uuid, rfc_emisor, rfc_receptor, nombre_receptor, tipo_comprobante, metodo_pago,
total, total_mxn, status, fecha_emision, source, facturapi_id
FROM cfdis
WHERE (source = 'facturapi' OR facturapi_id IS NOT NULL)
AND fecha_emision >= NOW() - interval '7 days'
ORDER BY fecha_emision DESC
LIMIT 20`,
);
if (recientes.length === 0) console.log(' (ninguno)');
for (const r of recientes) {
const emisor = r.rfc_emisor || '<NULL>';
const receptor = r.rfc_receptor || '<NULL>';
console.log(` ${r.uuid}`);
console.log(` EMISOR=${emisor} RECEPTOR=${receptor} (${r.nombre_receptor})`);
console.log(` tipo=${r.tipo_comprobante}/${r.metodo_pago} total=${r.total} status=${r.status} source=${r.source}`);
console.log(` fecha_emision=${r.fecha_emision?.toISOString?.() || r.fecha_emision}`);
console.log(` facturapi_id=${r.facturapi_id}`);
}
// 2) CFDIs totales en últimas 2 horas (cualquier emisor, cualquier source)
console.log(`\n>> CFDIs insertados en últimas 2 horas (cualquier source):`);
const { rows: ultimas } = await pool.query(
`SELECT uuid, rfc_emisor, rfc_receptor, tipo_comprobante, total,
status, fecha_emision, source, facturapi_id
FROM cfdis
WHERE fecha_emision >= NOW() - interval '2 hours'
ORDER BY fecha_emision DESC
LIMIT 20`,
);
if (ultimas.length === 0) console.log(' (ninguno)');
for (const r of ultimas) {
console.log(` ${r.uuid} | ${r.rfc_emisor}${r.rfc_receptor}`);
console.log(` tipo=${r.tipo_comprobante} total=${r.total} status=${r.status} source=${r.source}`);
console.log(` facturapi_id=${r.facturapi_id || 'null'}`);
}
// 3) Distribución de source en toda la BD
console.log(`\n>> Distribución de 'source' en cfdis:`);
const { rows: dist } = await pool.query(
`SELECT source, COUNT(*)::int AS cnt
FROM cfdis
GROUP BY source
ORDER BY cnt DESC`,
);
for (const r of dist) {
console.log(` source=${r.source || 'NULL'}${r.cnt}`);
}
await prisma.$disconnect();
}
main().catch(async e => {
console.error(e);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,36 @@
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' },
select: { id: true, databaseName: true },
});
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows } = await pool.query(
`SELECT * FROM rfcs WHERE id IN (23709, 1) ORDER BY id`,
);
for (const r of rows) {
console.log(`\nrfcs id=${r.id}:`);
for (const k of Object.keys(r).sort()) {
console.log(` ${k} = ${r[k]}`);
}
}
// Also look at all 4 Facturapi CFDIs' emisor fields
const { rows: all4 } = await pool.query(
`SELECT uuid, rfc_emisor, nombre_emisor, rfc_emisor_id, regimen_fiscal_emisor,
rfc_receptor, nombre_receptor, subtotal, total, xml_original IS NULL AS no_xml
FROM cfdis WHERE source='facturapi' ORDER BY fecha_emision DESC`,
);
console.log(`\n=== Todas las CFDIs source=facturapi (${all4.length}) ===`);
for (const r of all4) {
console.log(` ${r.uuid} | emisor='${r.rfc_emisor}' (id=${r.rfc_emisor_id}, nombre='${r.nombre_emisor}', regimen=${r.regimen_fiscal_emisor})`);
console.log(` receptor='${r.rfc_receptor}' (${r.nombre_receptor}) subtotal=${r.subtotal} total=${r.total} xml_missing=${r.no_xml}`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,63 @@
import { prisma, tenantDb } from '../src/config/database.js';
const uuid = (process.argv[2] || '5c874749-748f-11f0-96b1-2b9310891836').toLowerCase();
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows } = await pool.query(
`SELECT
c.uuid, c.total_mxn,
COALESCE((
SELECT SUM(COALESCE(p.monto_pago_mxn, 0))
FROM cfdis p
WHERE p.tipo_comprobante = 'P'
AND LOWER(COALESCE(p.uuid_relacionado, '')) LIKE '%' || LOWER(c.uuid) || '%'
AND p.status NOT IN ('Cancelado', '0')
), 0) AS pagos_p,
COALESCE((
SELECT SUM(COALESCE(e.total_mxn, 0))
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND COALESCE(e.cfdi_tipo_relacion, '') <> '07'
AND e.cfdis_relacionados IS NOT NULL
AND LOWER(c.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND e.status NOT IN ('Cancelado', '0')
), 0) AS ncs,
CASE WHEN c.cfdi_tipo_relacion = '07' AND c.cfdis_relacionados IS NOT NULL THEN
COALESCE((
SELECT SUM(COALESCE(a.total_mxn, 0))
FROM cfdis a
WHERE LOWER(a.uuid) = ANY(string_to_array(LOWER(c.cfdis_relacionados), '|'))
AND a.status NOT IN ('Cancelado', '0')
), 0) ELSE 0 END AS anticipo_aplicado,
(
COALESCE(c.total_mxn, 0)
- COALESCE((SELECT SUM(COALESCE(p.monto_pago_mxn, 0)) FROM cfdis p
WHERE p.tipo_comprobante = 'P'
AND LOWER(COALESCE(p.uuid_relacionado, '')) LIKE '%' || LOWER(c.uuid) || '%'
AND p.status NOT IN ('Cancelado', '0')), 0)
- COALESCE((SELECT SUM(COALESCE(e.total_mxn, 0)) FROM cfdis e
WHERE e.tipo_comprobante = 'E' AND COALESCE(e.cfdi_tipo_relacion,'') <> '07'
AND e.cfdis_relacionados IS NOT NULL
AND LOWER(c.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND e.status NOT IN ('Cancelado','0')), 0)
- CASE WHEN c.cfdi_tipo_relacion = '07' AND c.cfdis_relacionados IS NOT NULL THEN
COALESCE((SELECT SUM(COALESCE(a.total_mxn,0)) FROM cfdis a
WHERE LOWER(a.uuid) = ANY(string_to_array(LOWER(c.cfdis_relacionados),'|'))
AND a.status NOT IN ('Cancelado','0')), 0)
ELSE 0 END
) AS saldo_computado
FROM cfdis c WHERE LOWER(c.uuid) = $1`,
[uuid],
);
if (rows.length === 0) continue;
console.log(`[${t.rfc}]`, rows[0]);
}
await prisma.$disconnect();
}
main().catch(async (e) => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,37 @@
process.env.METRICAS_BYPASS_CACHE = '1';
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularIngresosPorRegimen, calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
import { getResumenIva } from '../src/services/impuestos.service.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
const año = Number(process.argv[4] || '2025');
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
console.log(`\n=== IVA trasladado/acreditable vs ingresos/gastos — ${año} contrib=${contribuyenteId} ===\n`);
console.log('Mes | Ingresos | IVA tras | Ratio | Gastos | IVA acred | Ratio ');
for (let m = 1; m <= 12; m++) {
const lastDay = new Date(año, m, 0).getDate();
const mm = String(m).padStart(2, '0');
const fi = `${año}-${mm}-01`;
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
const [ing, gas, iva] = await Promise.all([
calcularIngresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
calcularEgresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
getResumenIva(pool, fi, ff, tenant.id, false, contribuyenteId),
]);
const rTras = ing.total > 0 ? (iva.trasladado / ing.total) * 100 : 0;
const rAcr = gas.total > 0 ? (iva.acreditable / gas.total) * 100 : 0;
const flagT = Math.abs(rTras - 16) > 3 && ing.total > 0 ? '⚠️' : '';
const flagA = Math.abs(rAcr - 16) > 3 && gas.total > 0 ? '⚠️' : '';
console.log(`${mm} | ${ing.total.toFixed(2).padStart(12)} | ${iva.trasladado.toFixed(2).padStart(13)} | ${rTras.toFixed(1).padStart(5)}%${flagT} | ${gas.total.toFixed(2).padStart(12)} | ${iva.acreditable.toFixed(2).padStart(13)} | ${rAcr.toFixed(1).padStart(5)}%${flagA}`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,36 @@
process.env.METRICAS_BYPASS_CACHE = '1';
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
import { getResumenIva } from '../src/services/impuestos.service.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
const año = Number(process.argv[4] || '2025');
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
console.log(`\n=== IVA acreditable vs Gastos por mes — ${año} contrib=${contribuyenteId} ===\n`);
console.log('Mes | Gastos | IVA acreditable | Ratio | Esperado (16%) | Diff');
for (let m = 1; m <= 12; m++) {
const lastDay = new Date(año, m, 0).getDate();
const mm = String(m).padStart(2, '0');
const fi = `${año}-${mm}-01`;
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
const [gastos, iva] = await Promise.all([
calcularEgresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
getResumenIva(pool, fi, ff, tenant.id, false, contribuyenteId),
]);
const ratio = gastos.total > 0 ? (iva.acreditable / gastos.total) * 100 : 0;
const esperado = gastos.total * 0.16;
const diff = iva.acreditable - esperado;
const flag = Math.abs(ratio - 16) > 3 && gastos.total > 0 ? ' ⚠️' : '';
console.log(`${mm} | ${gastos.total.toFixed(2).padStart(13)} | ${iva.acreditable.toFixed(2).padStart(15)} | ${ratio.toFixed(2)}% | ${esperado.toFixed(2).padStart(13)} | ${diff.toFixed(2)}${flag}`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,22 @@
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
for (const t of tenants) {
let pool;
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
console.log(`\n=== ${t.rfc} ===`);
const { rows } = await pool.query(`
SELECT tipo_comprobante, metodo_pago, COUNT(*)::int AS cnt
FROM cfdis
WHERE cfdi_tipo_relacion = '07' AND status NOT IN ('Cancelado','0')
GROUP BY tipo_comprobante, metodo_pago
ORDER BY cnt DESC
`);
for (const r of rows) {
console.log(` ${r.tipo_comprobante}/${r.metodo_pago || 'null'}: ${r.cnt}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,27 @@
import { prisma, tenantDb } from '../src/config/database.js';
const RFC = 'TOAH680201RA2';
async function main() {
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
for (const t of tenants) {
let pool;
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
const { rows } = await pool.query(`
SELECT tipo_comprobante, metodo_pago, cfdi_tipo_relacion, COUNT(*)::int AS cnt
FROM cfdis
WHERE (UPPER(rfc_emisor) = $1 OR UPPER(rfc_receptor) = $1)
AND status NOT IN ('Cancelado','0')
AND cfdi_tipo_relacion IS NOT NULL
GROUP BY tipo_comprobante, metodo_pago, cfdi_tipo_relacion
ORDER BY cnt DESC`,
[RFC]);
if (rows.length === 0) continue;
console.log(`\n=== ${t.rfc} (${RFC}) ===`);
for (const r of rows) {
console.log(` ${r.tipo_comprobante}/${r.metodo_pago || '?'}/rel=${r.cfdi_tipo_relacion}: ${r.cnt}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,26 @@
import { prisma } from '../src/config/database.js';
import { hashPassword } from '../src/utils/password.js';
async function main() {
const ivan = await prisma.user.findUnique({ where: { email: 'ivan@horuxfin.com' }, include: { tenant: true } });
if (!ivan) { console.error('Ivan not found'); process.exit(1); }
console.log('Tenant:', ivan.tenant.nombre, '(', ivan.tenant.id, ')');
const existing = await prisma.user.findUnique({ where: { email: 'carlos@horuxfin.com' } });
if (existing) { console.log('Carlos already exists:', existing.id); process.exit(0); }
const hash = await hashPassword('Aasi940812');
const carlos = await prisma.user.create({
data: {
tenantId: ivan.tenantId,
email: 'carlos@horuxfin.com',
passwordHash: hash,
nombre: 'Carlos Horux',
role: 'admin',
}
});
console.log('Carlos created:', carlos.id, carlos.email, carlos.role);
}
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,88 @@
/**
* Desglosa cada I/07 recibida de un contribuyente en un rango, mostrando:
* - NETO_CUSTOM(I/07)
* - UUIDs en cfdis_relacionados
* - NETO_CUSTOM de cada relacionada vigente
* - Contribución neta de la I/07 al gasto
*
* Útil para detectar:
* - Múltiples I/07 que referencian el mismo anticipo (doble-resta)
* - Anticipos fuera del periodo que dominan la compensación
* - UUIDs relacionados incorrectos (apuntan a CFDIs enormes no-anticipo)
*/
import { prisma, tenantDb } from '../src/config/database.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
const yearMonth = process.argv[4] || '2025-07';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const NETO = (a: string) => `(
COALESCE(${a}.total_mxn,0) - COALESCE(${a}.iva_traslado_mxn,0) + COALESCE(${a}.iva_retencion_mxn,0)
+ COALESCE(${a}.isr_retencion_mxn,0)
- COALESCE(${a}.ieps_traslado_mxn,0) + COALESCE(${a}.ieps_retencion_mxn,0)
- COALESCE(${a}.impuestos_locales_trasladado_mxn,0) + COALESCE(${a}.impuestos_locales_retenidos_mxn,0)
)`;
const { rows } = await pool.query(
`SELECT c.uuid, c.fecha_emision, c.total_mxn, c.rfc_emisor, c.cfdis_relacionados,
${NETO('c')} AS neto_i07
FROM cfdis c
WHERE c.type='RECIBIDO' AND c.tipo_comprobante='I' AND c.metodo_pago='PUE'
AND c.cfdi_tipo_relacion='07'
AND c.status NOT IN ('Cancelado','0')
AND c.fecha_emision >= $1::date AND c.fecha_emision < ($2::date + interval '1 day')
AND c.contribuyente_id = $3
ORDER BY c.fecha_emision`,
[fi, ff, contribuyenteId],
);
console.log(`\n=== I/07 RECIBIDAS en ${fi} a ${ff} ===`);
console.log(`Total I/07: ${rows.length}`);
let sumContrib = 0;
for (const r of rows) {
const relsUuids = (r.cfdis_relacionados || '').split('|').filter(Boolean).map((u: string) => u.toLowerCase());
console.log(`\n I/07 ${r.uuid.substring(0,8)} — fecha=${r.fecha_emision.toISOString().slice(0,10)} — emisor=${r.rfc_emisor}`);
console.log(` total_mxn: ${Number(r.total_mxn).toFixed(2)}`);
console.log(` NETO(I/07): ${Number(r.neto_i07).toFixed(2)}`);
console.log(` relacionados (${relsUuids.length}):`);
let sumRel = 0;
if (relsUuids.length > 0) {
const { rows: rels } = await pool.query(
`SELECT uuid, fecha_emision, total_mxn, tipo_comprobante, metodo_pago, status, ${NETO('a')} AS neto_rel
FROM cfdis a
WHERE LOWER(a.uuid) = ANY($1::text[])`,
[relsUuids],
);
for (const rel of rels) {
const vig = rel.status === 'Vigente' ? '✓' : '✗';
console.log(` ${vig} ${rel.uuid.substring(0,8)} ${rel.tipo_comprobante} ${rel.metodo_pago || '-'} fecha=${rel.fecha_emision?.toISOString?.().slice(0,10) || '-'} total=${Number(rel.total_mxn).toFixed(2)} NETO=${Number(rel.neto_rel).toFixed(2)}`);
if (rel.status === 'Vigente') sumRel += Number(rel.neto_rel);
}
const missing = relsUuids.filter((u: string) => !rels.find((x: any) => x.uuid.toLowerCase() === u));
if (missing.length > 0) {
console.log(` ⚠️ ${missing.length} UUID(s) relacionados NO están en BD:`);
for (const m of missing) console.log(` ${m}`);
}
}
const contrib = Number(r.neto_i07) - sumRel;
sumContrib += contrib;
console.log(` Σ NETO(rel vigentes): ${sumRel.toFixed(2)}`);
console.log(` CONTRIB: ${contrib.toFixed(2)} ${contrib < 0 ? '⚠️ NEGATIVA' : ''}`);
}
console.log(`\nSuma total contribuciones I/07: ${sumContrib.toFixed(2)}`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,104 @@
/**
* Amplía la inspección: lista TODOS los CFDIs de mayo-2025 donde Horux 360
* aparece como emisor o receptor, marcando cuáles entran al bucket ingresos
* y cuáles no + por qué.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
const CONTRIB_ID = 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
const FI = '2025-05-01';
const FF = '2025-05-31';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC }, select: { id: true, databaseName: true },
});
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const ctx = await resolveContribuyenteContext(pool, tenant.id, CONTRIB_ID);
console.log(`\n=== TODOS los CFDIs de Horux 360 en mayo-2025 (como emisor o receptor) ===\n`);
const { rows } = await pool.query(
`SELECT uuid, type, tipo_comprobante, metodo_pago, status,
regimen_fiscal_emisor, regimen_fiscal_receptor,
rfc_emisor, rfc_receptor, nombre_receptor, nombre_emisor,
total_mxn, monto_pago_mxn, cfdi_tipo_relacion, fecha_emision, source
FROM cfdis
WHERE ((${ctx.esEmisor}) OR (${ctx.esReceptor}))
AND fecha_emision >= $1::date
AND fecha_emision < ($2::date + interval '1 day')
ORDER BY fecha_emision, tipo_comprobante, total_mxn DESC`,
[FI, FF],
);
console.log(`Total CFDIs encontrados: ${rows.length}\n`);
const buckets: Record<string, any[]> = {
ingresosG1: [],
ingresosG3: [],
ingresosSueldos: [],
noIncluye_canceladoOinvalido: [],
noIncluye_regimenFuera: [],
noIncluye_comoReceptor: [],
noIncluye_otroMotivo: [],
};
const G1 = ['606', '612', '621', '625', '626'];
const G3 = ['601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624'];
for (const r of rows) {
const cancel = ['Cancelado', '0'].includes(r.status);
const esEmisorRow = String(r.rfc_emisor).toUpperCase() === 'HTS240708LJA';
const regE = r.regimen_fiscal_emisor;
const regR = r.regimen_fiscal_receptor;
if (cancel) { buckets.noIncluye_canceladoOinvalido.push(r); continue; }
if (esEmisorRow) {
if (G1.includes(regE)) {
if ((r.tipo_comprobante === 'I' && r.metodo_pago === 'PUE') ||
(r.tipo_comprobante === 'P') ||
(r.tipo_comprobante === 'E' && r.metodo_pago === 'PUE')) {
buckets.ingresosG1.push(r); continue;
}
}
if (G3.includes(regE)) {
if ((r.tipo_comprobante === 'I' && ['PUE', 'PPD'].includes(r.metodo_pago)) ||
(r.tipo_comprobante === 'E' && r.metodo_pago === 'PUE')) {
buckets.ingresosG3.push(r); continue;
}
}
if (!G1.includes(regE) && !G3.includes(regE)) {
buckets.noIncluye_regimenFuera.push({ ...r, reason: `emisor régimen ${regE} fuera de grupo` });
continue;
}
buckets.noIncluye_otroMotivo.push({ ...r, reason: `emisor tipo=${r.tipo_comprobante}/${r.metodo_pago} no matchea` });
continue;
}
// No emisor → receptor
if (r.tipo_comprobante === 'N' && r.metodo_pago === 'PUE' && regR === '605') {
buckets.ingresosSueldos.push(r); continue;
}
buckets.noIncluye_comoReceptor.push({ ...r, reason: 'es receptor, no cuenta como ingreso (salvo N/605)' });
}
const fmt = (n: any) => Number(n || 0).toFixed(2);
for (const [name, list] of Object.entries(buckets)) {
if (list.length === 0) continue;
console.log(`\n--- ${name} (${list.length}) ---`);
for (const r of list) {
const fe = r.fecha_emision?.toISOString?.()?.slice(0, 10) || r.fecha_emision;
const reason = r.reason ? ` | ${r.reason}` : '';
console.log(` ${fe} ${r.tipo_comprobante}/${r.metodo_pago || '-'} status=${r.status} regE=${r.regimen_fiscal_emisor} regR=${r.regimen_fiscal_receptor} ${r.rfc_emisor}${r.rfc_receptor} total=${fmt(r.total_mxn)} mp=${fmt(r.monto_pago_mxn)} ${r.uuid.substring(0,8)}${reason}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,111 @@
/**
* Debug ingresos Horux 360 mayo-2025 post-Método A:
* - Llama al KPI (calcularIngresosPorRegimen)
* - Lista los CFDIs que entran al drill-down (mismos filtros del controller)
* - Suma manualmente para ver dónde está la discrepancia
*/
process.env.METRICAS_BYPASS_CACHE = '1';
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularIngresosPorRegimen, GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../src/services/dashboard.service.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
const CONTRIB_ID = 'b3761db6-0b8d-4251-8078-4ddc31e9c75b'; // Horux 360
const FI = '2025-05-01';
const FF = '2025-05-31';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC },
select: { id: true, databaseName: true },
});
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const ctx = await resolveContribuyenteContext(pool, tenant.id, CONTRIB_ID);
console.log(`\n=== KPI calcularIngresosPorRegimen ===`);
const kpi = await calcularIngresosPorRegimen(
pool, tenant.id, FI, FF, undefined, undefined, false, CONTRIB_ID,
);
console.log(`Total KPI: ${kpi.total.toFixed(2)}`);
for (const r of kpi.porRegimen) {
console.log(` ${r.regimenClave} ${r.regimenDescripcion.substring(0, 40).padEnd(40)} ${r.monto.toFixed(2)}`);
}
// Replica de los filtros del drill-down bucket 'ingresos' (cfdi.controller.ts:163-187)
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
const CLAVES = `('84121603','93161608','85101501','85121800')`;
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0)-COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES}),0)`;
const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(',');
const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(',');
const drillSql = `
SELECT id, uuid, type, tipo_comprobante, metodo_pago, regimen_fiscal_emisor,
regimen_fiscal_receptor, rfc_emisor, rfc_receptor, nombre_receptor,
total_mxn, iva_traslado_mxn, ieps_traslado_mxn, impuestos_locales_trasladado_mxn,
monto_pago_mxn, iva_traslado_pago_mxn, ieps_traslado_pago_mxn,
cfdi_tipo_relacion, fecha_emision, fecha_pago_p, source,
-- neto (lo que "contribuye" a ingresos según grupo)
CASE
WHEN tipo_comprobante='I' THEN (COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO}))
WHEN tipo_comprobante='E' THEN -(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO}))
WHEN tipo_comprobante='P' THEN (COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}))
WHEN tipo_comprobante='N' THEN COALESCE(total_mxn,0)
ELSE 0
END AS aporte
FROM cfdis
WHERE ${VIGENTE}
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
AND (
(${ctx.esEmisor} AND regimen_fiscal_emisor IN (${g1}) AND (
(tipo_comprobante='I' AND metodo_pago='PUE')
OR (tipo_comprobante='P')
OR (tipo_comprobante='E' AND metodo_pago='PUE')
))
OR (${ctx.esReceptor} AND tipo_comprobante='N' AND metodo_pago='PUE' AND regimen_fiscal_receptor='605')
OR (${ctx.esEmisor} AND regimen_fiscal_emisor IN (${g3}) AND (
(tipo_comprobante='I' AND metodo_pago IN ('PUE','PPD'))
OR (tipo_comprobante='E' AND metodo_pago='PUE')
))
)
ORDER BY fecha_emision, tipo_comprobante, total_mxn DESC
`;
const { rows } = await pool.query(drillSql, [FI, FF]);
console.log(`\n=== Drill-down (${rows.length} CFDIs) ===`);
let sumDrill = 0;
const perRegimen: Record<string, number> = {};
for (const r of rows) {
const aporte = Number(r.aporte || 0);
sumDrill += aporte;
const reg = r.regimen_fiscal_emisor || r.regimen_fiscal_receptor || '?';
perRegimen[reg] = (perRegimen[reg] || 0) + aporte;
const fe = r.fecha_emision?.toISOString?.()?.slice(0, 10) || r.fecha_emision;
const rel07 = r.cfdi_tipo_relacion === '07' ? ' [07]' : '';
const src = r.source === 'facturapi' ? ' [facturapi]' : '';
console.log(
` ${fe} ${r.tipo_comprobante}/${r.metodo_pago}${rel07}${src} ` +
`reg=${reg} ${String(r.rfc_emisor).padEnd(14)}${String(r.rfc_receptor).padEnd(14)} ` +
`total=${Number(r.total_mxn || 0).toFixed(2).padStart(10)} ` +
`aporte=${aporte.toFixed(2).padStart(10)} ${r.uuid.substring(0,8)}`
);
}
console.log(`\n=== Suma de aportes del drill-down: ${sumDrill.toFixed(2)} ===`);
console.log(`Por régimen (drill-down):`);
for (const [reg, monto] of Object.entries(perRegimen).sort()) {
console.log(` ${reg}: ${monto.toFixed(2)}`);
}
console.log(`\n=== Diferencia KPI drill: ${(kpi.total - sumDrill).toFixed(2)} ===`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,82 @@
/**
* CLI script to decrypt FIEL credentials from filesystem backup.
* Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>
*
* Decrypted files are written to /tmp/horux-fiel-<RFC>/ and auto-deleted after 30 minutes.
*/
import { readFile, writeFile, mkdir, rm } from 'fs/promises';
import { join } from 'path';
import { createDecipheriv, createHash } from 'crypto';
const FIEL_PATH = process.env.FIEL_STORAGE_PATH || '/var/horux/fiel';
const FIEL_KEY = process.env.FIEL_ENCRYPTION_KEY;
const rfc = process.argv[2];
if (!rfc) {
console.error('Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>');
process.exit(1);
}
if (!FIEL_KEY) {
console.error('Error: FIEL_ENCRYPTION_KEY environment variable is required');
process.exit(1);
}
function deriveKey(): Buffer {
return createHash('sha256').update(FIEL_KEY!).digest();
}
function decryptBuffer(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer {
const key = deriveKey();
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}
async function main() {
const fielDir = join(FIEL_PATH, rfc.toUpperCase());
const outputDir = `/tmp/horux-fiel-${rfc.toUpperCase()}`;
console.log(`Reading encrypted FIEL from: ${fielDir}`);
// Read encrypted certificate
const cerEnc = await readFile(join(fielDir, 'certificate.cer.enc'));
const cerIv = await readFile(join(fielDir, 'certificate.cer.iv'));
const cerTag = await readFile(join(fielDir, 'certificate.cer.tag'));
// Read encrypted private key
const keyEnc = await readFile(join(fielDir, 'private_key.key.enc'));
const keyIv = await readFile(join(fielDir, 'private_key.key.iv'));
const keyTag = await readFile(join(fielDir, 'private_key.key.tag'));
// Read and decrypt metadata
const metaEnc = await readFile(join(fielDir, 'metadata.json.enc'));
const metaIv = await readFile(join(fielDir, 'metadata.json.iv'));
const metaTag = await readFile(join(fielDir, 'metadata.json.tag'));
// Decrypt all
const cerData = decryptBuffer(cerEnc, cerIv, cerTag);
const keyData = decryptBuffer(keyEnc, keyIv, keyTag);
const metadata = JSON.parse(decryptBuffer(metaEnc, metaIv, metaTag).toString('utf-8'));
// Write decrypted files
await mkdir(outputDir, { recursive: true, mode: 0o700 });
await writeFile(join(outputDir, 'certificate.cer'), cerData, { mode: 0o600 });
await writeFile(join(outputDir, 'private_key.key'), keyData, { mode: 0o600 });
await writeFile(join(outputDir, 'metadata.json'), JSON.stringify(metadata, null, 2), { mode: 0o600 });
console.log(`\nDecrypted files written to: ${outputDir}`);
console.log('Metadata:', metadata);
console.log('\nFiles will be auto-deleted in 30 minutes.');
// Auto-delete after 30 minutes
setTimeout(async () => {
await rm(outputDir, { recursive: true, force: true });
console.log(`Cleaned up ${outputDir}`);
process.exit(0);
}, 30 * 60 * 1000);
}
main().catch((err) => {
console.error('Failed to decrypt FIEL:', err.message);
process.exit(1);
});

View File

@@ -0,0 +1,101 @@
/**
* Compara paso a paso los 3 componentes del cálculo de egresos 612 en Feb 2025:
* 1) Query exacto que usa calcularEgresosPorRegimen (con FECHA_RANGO / FECHA_PAGO_RANGO)
* 2) Vs el drill-down usando fecha efectiva por fila
* Detalle al CFDI para encontrar discrepancias.
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const fi = '2025-02-01';
const ff = '2025-02-28';
const contrib = 'd745a915-6a23-4818-944b-a7e1e18e536a';
const reg = '612';
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
const EXCL = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
// QUERY 1 FACTURAS (idéntico a calcularEgresosPorRegimen)
const f = await pool.query(
`SELECT uuid, total_mxn, (${IMP_TRAS}) AS imp, (${EXCL}) AS excl,
COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL}) AS neto,
cfdi_tipo_relacion AS rel
FROM cfdis
WHERE type='RECIBIDO' AND tipo_comprobante='I' AND metodo_pago='PUE'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
AND regimen_fiscal_receptor = $3
AND contribuyente_id = $4
ORDER BY fecha_emision`,
[fi, ff, reg, contrib],
);
const sumF = f.rows.reduce((s, r) => s + Number(r.neto), 0);
console.log(`FACTURAS I PUE reg=${reg}: n=${f.rows.length} sum_neto=${sumF.toFixed(2)}`);
// QUERY 2 PAGOS P
const p = await pool.query(
`SELECT uuid, monto_pago_mxn, (${IMP_TRAS_PAGO}) AS imp,
COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}) AS neto,
fecha_pago_p, fecha_emision
FROM cfdis
WHERE type='RECIBIDO' AND tipo_comprobante='P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')
AND regimen_fiscal_receptor = $3
AND contribuyente_id = $4
ORDER BY fecha_pago_p`,
[fi, ff, reg, contrib],
);
const sumP = p.rows.reduce((s, r) => s + Number(r.neto), 0);
console.log(`PAGOS P reg=${reg} (fecha_pago_p): n=${p.rows.length} sum_neto=${sumP.toFixed(2)}`);
// También probar con fecha_emision del P (alternativo)
const pEmis = await pool.query(
`SELECT uuid, COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}) AS neto,
fecha_pago_p, fecha_emision
FROM cfdis
WHERE type='RECIBIDO' AND tipo_comprobante='P'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
AND regimen_fiscal_receptor = $3
AND contribuyente_id = $4
ORDER BY fecha_emision`,
[fi, ff, reg, contrib],
);
const sumPe = pEmis.rows.reduce((s, r) => s + Number(r.neto), 0);
console.log(` (alt) PAGOS P filtrados por fecha_emision: n=${pEmis.rows.length} sum_neto=${sumPe.toFixed(2)}`);
// QUERY 3 NC
const n = await pool.query(
`SELECT uuid, total_mxn, (${IMP_TRAS}) AS imp, (${EXCL}) AS excl,
COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL}) AS neto,
cfdi_tipo_relacion AS rel
FROM cfdis
WHERE type='RECIBIDO' AND tipo_comprobante='E' AND metodo_pago='PUE'
AND COALESCE(cfdi_tipo_relacion,'') <> '07'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
AND regimen_fiscal_receptor = $3
AND contribuyente_id = $4`,
[fi, ff, reg, contrib],
);
const sumN = n.rows.reduce((s, r) => s + Number(r.neto), 0);
console.log(`NC E PUE excl 07 reg=${reg}: n=${n.rows.length} sum_neto=${sumN.toFixed(2)}`);
console.log(`\nTotal ON-THE-FLY (reg 612): ${(sumF + sumP - sumN).toFixed(2)}`);
console.log(`Cache dice: 446180.10`);
console.log(`Delta: ${((sumF + sumP - sumN) - 446180.10).toFixed(2)}`);
// Detalle de los P para investigar — fecha_emision vs fecha_pago_p
console.log(`\nDetalle PAGOS P (filtrados por fecha_pago_p):`);
for (const r of p.rows) {
console.log(` ${r.uuid.substring(0,8)} monto=${Number(r.monto_pago_mxn).toFixed(2)} neto=${Number(r.neto).toFixed(2)} fecha_pago_p=${r.fecha_pago_p?.toISOString?.()?.slice(0,10)} fecha_emision=${r.fecha_emision?.toISOString?.()?.slice(0,10)}`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,55 @@
/** Detalle neto de cada CFDI del dashboard para Horux 360 mayo 2025. */
import { prisma, tenantDb } from '../src/config/database.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const ctx = await resolveContribuyenteContext(pool, tenant.id, 'b3761db6-0b8d-4251-8078-4ddc31e9c75b');
// Facturas I PUE (rendición con la misma lógica de g1Facturas)
const { rows: fact } = await pool.query(
`SELECT uuid, total_mxn,
iva_traslado_mxn, ieps_traslado_mxn, impuestos_locales_trasladado_mxn,
iva_retencion_mxn, isr_retencion_mxn, ieps_retencion_mxn, impuestos_locales_retenidos_mxn,
cfdi_tipo_relacion,
(COALESCE(total_mxn,0) - COALESCE(iva_traslado_mxn,0) - COALESCE(ieps_traslado_mxn,0) - COALESCE(impuestos_locales_trasladado_mxn,0)) AS neto_normal
FROM cfdis
WHERE ${ctx.esEmisor} AND tipo_comprobante='I' AND metodo_pago='PUE'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= '2025-05-01'::date AND fecha_emision < '2025-05-31'::date + interval '1 day'
AND regimen_fiscal_emisor = '626'
ORDER BY fecha_emision`,
);
console.log(`\nI PUE régimen 626:`);
for (const r of fact) {
console.log(` ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2)} iva_tras=${Number(r.iva_traslado_mxn).toFixed(2)} iva_ret=${Number(r.iva_retencion_mxn).toFixed(2)} isr_ret=${Number(r.isr_retencion_mxn).toFixed(2)} neto=${Number(r.neto_normal).toFixed(2)} rel=${r.cfdi_tipo_relacion || '-'}`);
}
const factNeto = fact.reduce((s, r) => s + Number(r.neto_normal), 0);
console.log(` Suma neto facturas: ${factNeto.toFixed(2)}`);
// Pagos P
const { rows: pagos } = await pool.query(
`SELECT uuid, fecha_pago_p, monto_pago_mxn,
iva_traslado_pago_mxn, ieps_traslado_pago_mxn,
iva_retencion_pago_mxn, isr_retencion_pago_mxn, ieps_retencion_pago_mxn,
(COALESCE(monto_pago_mxn,0) - COALESCE(iva_traslado_pago_mxn,0) - COALESCE(ieps_traslado_pago_mxn,0)) AS neto_normal
FROM cfdis
WHERE ${ctx.esEmisor} AND tipo_comprobante='P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= '2025-05-01'::date AND fecha_pago_p < '2025-05-31'::date + interval '1 day'
AND regimen_fiscal_emisor = '626'
ORDER BY fecha_pago_p`,
);
console.log(`\nPagos P régimen 626:`);
for (const r of pagos) {
console.log(` ${r.uuid.substring(0,8)} monto_pago=${Number(r.monto_pago_mxn).toFixed(2)} iva_tras_pago=${Number(r.iva_traslado_pago_mxn).toFixed(2)} iva_ret_pago=${Number(r.iva_retencion_pago_mxn).toFixed(2)} neto=${Number(r.neto_normal).toFixed(2)}`);
}
const pagosNeto = pagos.reduce((s, r) => s + Number(r.neto_normal), 0);
console.log(` Suma neto pagos: ${pagosNeto.toFixed(2)}`);
console.log(`\nTOTAL facturas + pagos: ${(factNeto + pagosNeto).toFixed(2)}`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,68 @@
/** Breakdown: qué CFDIs contribuyen al IVA acreditable vs al gasto. */
import { prisma, tenantDb } from '../src/config/database.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
const yearMonth = process.argv[4] || '2025-12';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
// I PUE recibidas
const { rows: facturas } = await pool.query(
`SELECT uuid, total_mxn, iva_traslado_mxn, cfdi_tipo_relacion, cfdis_relacionados,
(COALESCE(total_mxn,0) - (${IMP_TRAS})) AS neto_normal
FROM cfdis
WHERE ${ctx.esReceptor} AND tipo_comprobante='I' AND metodo_pago='PUE'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
ORDER BY total_mxn DESC`,
[fi, ff],
);
console.log(`\n=== I PUE recibidas ${yearMonth} ===`);
console.log(`# | UUID | total | IVA | neto_normal | rel | cfdis_relacionados`);
for (const r of facturas) {
const rel = r.cfdi_tipo_relacion || '-';
const cr = r.cfdis_relacionados ? `${r.cfdis_relacionados.substring(0,36)}` : '';
console.log(` ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2).padStart(12)} IVA=${Number(r.iva_traslado_mxn).toFixed(2).padStart(10)} neto=${Number(r.neto_normal).toFixed(2).padStart(12)} rel=${rel.padEnd(3)}${cr}`);
}
// I PUE recibidas con relación 07 — verificar si el anticipo está en otro mes
const i07 = facturas.filter((r: any) => r.cfdi_tipo_relacion === '07');
if (i07.length > 0) {
console.log(`\nI/07 recibidas en ${yearMonth}: ${i07.length}`);
for (const r of i07) {
const relsUuids = (r.cfdis_relacionados || '').split('|').filter(Boolean).map((u: string) => u.toLowerCase());
if (relsUuids.length > 0) {
const { rows: rels } = await pool.query(
`SELECT uuid, fecha_emision, total_mxn, iva_traslado_mxn
FROM cfdis a
WHERE LOWER(a.uuid) = ANY($1::text[])
AND a.status NOT IN ('Cancelado','0')`,
[relsUuids],
);
console.log(`\n I/07 ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2)} IVA=${Number(r.iva_traslado_mxn).toFixed(2)}`);
for (const a of rels) {
const fecha = a.fecha_emision.toISOString().slice(0,10);
const fuera = fecha.substring(0,7) !== yearMonth ? ' ← FUERA DEL MES' : '';
console.log(` anticipo ${a.uuid.substring(0,8)} fecha=${fecha} total=${Number(a.total_mxn).toFixed(2)} IVA=${Number(a.iva_traslado_mxn).toFixed(2)}${fuera}`);
}
}
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,88 @@
/**
* Simula el drill-down bucket=ingresos para un contribuyente/mes y muestra
* cada CFDI que aparecería en el drill. Permite comparar con el total del
* dashboard.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
const yearMonth = process.argv[4] || '2025-05';
const GRUPO_PF_EMPRESARIAL = ['606', '612', '621', '625', '626'];
const GRUPO_PM_OTROS = ['601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624'];
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
const esEmisor = ctx.esEmisor;
const esReceptor = ctx.esReceptor;
const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(',');
const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(',');
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
// Query idéntico al drill-down bucket=ingresos
const { rows } = await pool.query(
`SELECT uuid, tipo_comprobante, metodo_pago,
regimen_fiscal_emisor, regimen_fiscal_receptor,
cfdi_tipo_relacion,
total_mxn, monto_pago_mxn,
fecha_emision, fecha_pago_p
FROM cfdis
WHERE 1=1
AND (
(
${esEmisor}
AND regimen_fiscal_emisor IN (${g1})
AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR tipo_comprobante = 'P'
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND COALESCE(cfdi_tipo_relacion, '') <> '07')
)
)
OR (
${esReceptor}
AND tipo_comprobante = 'N' AND metodo_pago = 'PUE'
AND regimen_fiscal_receptor = '605'
)
OR (
${esEmisor}
AND regimen_fiscal_emisor IN (${g3})
AND (
(tipo_comprobante = 'I' AND metodo_pago IN ('PUE','PPD'))
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE')
)
)
)
AND status NOT IN ('Cancelado','0')
AND ${FECHA_EFECTIVA} >= $1::date
AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')
ORDER BY ${FECHA_EFECTIVA}`,
[fi, ff],
);
console.log(`\n=== Drill bucket=ingresos ${yearMonth} contrib=${ctx.rfc} ===`);
console.log(`Filas: ${rows.length}\n`);
let sumTotal = 0, sumPago = 0;
for (const r of rows) {
console.log(` ${r.uuid.substring(0,8)} ${r.tipo_comprobante}${r.metodo_pago ? '/' + r.metodo_pago : ''}${r.cfdi_tipo_relacion ? ' rel=' + r.cfdi_tipo_relacion : ''} reg=${r.regimen_fiscal_emisor || r.regimen_fiscal_receptor} total=${Number(r.total_mxn || 0).toFixed(2)} pago=${Number(r.monto_pago_mxn || 0).toFixed(2)}`);
sumTotal += Number(r.total_mxn || 0);
sumPago += Number(r.monto_pago_mxn || 0);
}
console.log(`\nSuma total_mxn (bruto drill): ${sumTotal.toFixed(2)}`);
console.log(`Suma monto_pago_mxn: ${sumPago.toFixed(2)}`);
console.log(`(Total bruto cuenta I + E a total, y P a monto_pago)`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Extrae el texto del PDF de términos y condiciones y lo convierte en un
* módulo TypeScript para que el frontend lo renderice sin tener que parsear
* el PDF en runtime.
*
* Además copia el PDF original a `apps/web/public/legal/` para servirlo como
* descarga.
*
* Uso:
* pnpm legal:sync
*
* Cuando se actualiza el documento legal:
* 1. Reemplazar `docs/legal/Terminos y condiciones.pdf` por la nueva versión
* (mismo nombre de archivo).
* 2. Correr `pnpm legal:sync`.
* 3. Commit de los cambios (PDF, terminos.ts, PDF copy).
*/
import { readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PDFParse } from 'pdf-parse';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '../../../');
const SRC_PDF = resolve(ROOT, 'docs/legal/Terminos y condiciones.pdf');
const DEST_PDF = resolve(ROOT, 'apps/web/public/legal/terminos-y-condiciones.pdf');
const DEST_TS = resolve(ROOT, 'apps/web/content/terminos.ts');
async function main() {
console.log('[legal:sync] Leyendo:', SRC_PDF);
const buf = readFileSync(SRC_PDF);
const parser = new PDFParse({ data: buf });
const textResult = await parser.getText();
await parser.destroy();
const rawText = (textResult.text ?? '').trim();
const pages = textResult.total ?? textResult.pages?.length ?? 0;
if (!rawText) {
console.error('[legal:sync] ERROR: el PDF no contiene texto extraíble (¿escaneado sin OCR?).');
process.exit(1);
}
// Copia el PDF a public/ para que sea descargable
mkdirSync(dirname(DEST_PDF), { recursive: true });
copyFileSync(SRC_PDF, DEST_PDF);
// Escribe el texto como módulo TypeScript. Escapa backticks para que el
// template literal no rompa si el PDF los contiene.
const escaped = rawText.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
const extractedAt = new Date().toISOString();
const content = `// AUTO-GENERADO por \`pnpm legal:sync\`. NO editar a mano.
// Fuente: docs/legal/Terminos y condiciones.pdf
// Regenerar tras actualizar el PDF.
export const TERMINOS_TEXT = \`${escaped}\`;
export const TERMINOS_META = {
extractedAt: '${extractedAt}',
pages: ${pages},
chars: ${rawText.length},
} as const;
`;
mkdirSync(dirname(DEST_TS), { recursive: true });
writeFileSync(DEST_TS, content, 'utf8');
console.log(`[legal:sync] OK: ${rawText.length} chars extraídos, ${pages} páginas.`);
console.log(`[legal:sync] → ${DEST_PDF}`);
console.log(`[legal:sync] → ${DEST_TS}`);
}
main().catch(err => {
console.error('[legal:sync] FAIL:', err);
process.exit(1);
});

View File

@@ -0,0 +1,28 @@
import { prisma, tenantDb } from '../src/config/database.js';
const term = (process.argv[2] || '').toLowerCase();
if (!term) { console.error('Usage: tsx scripts/find-contribuyente.ts <texto>'); process.exit(1); }
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows: cols } = await pool.query(
`SELECT column_name FROM information_schema.columns WHERE table_name='contribuyentes'`,
);
const colNames = cols.map((c: any) => c.column_name);
const nameCols = colNames.filter(n => n.includes('nombre') || n.includes('razon'));
const filterSql = nameCols.map(c => `LOWER(${c}) LIKE '%${term}%'`).join(' OR ');
if (!filterSql) continue;
const { rows } = await pool.query(`SELECT entidad_id, rfc, ${nameCols.join(',')} FROM contribuyentes WHERE ${filterSql}`);
if (rows.length > 0) {
console.log(`\n[${t.rfc}]`);
for (const r of rows) console.log(r);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,75 @@
/**
* Encuentra E que referencien directamente a una I/07 PPD vía
* `cfdis_relacionados`. Patrón real observado: la E "ajusta" la I/07 PPD,
* no al anticipo original. La I/07 PPD apunta al anticipo, la E apunta a
* la I/07 PPD.
*/
import { prisma, tenantDb } from '../src/config/database.js';
const TARGET_RFC = process.argv[2];
async function main() {
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
for (const t of tenants) {
let pool;
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
console.log(`\n=== ${t.rfc}${TARGET_RFC ? ` (RFC=${TARGET_RFC})` : ''} ===`);
const rfcFilter = TARGET_RFC
? `AND (UPPER(i.rfc_emisor) = UPPER('${TARGET_RFC}') OR UPPER(i.rfc_receptor) = UPPER('${TARGET_RFC}'))`
: '';
const { rows } = await pool.query(`
SELECT
i.uuid AS i_uuid, i.fecha_emision AS i_fecha, i.total_mxn AS i_total,
i.iva_traslado_mxn AS i_iva, i.rfc_emisor AS i_emisor, i.rfc_receptor AS i_receptor,
i.type AS i_type,
e.uuid AS e_uuid, e.cfdi_tipo_relacion AS e_rel, e.metodo_pago AS e_mp,
e.fecha_emision AS e_fecha, e.total_mxn AS e_total, e.iva_traslado_mxn AS e_iva,
ABS(EXTRACT(EPOCH FROM (e.fecha_emision - i.fecha_emision)) / 86400)::int AS diff_dias,
EXTRACT(YEAR FROM i.fecha_emision)::int * 12 + EXTRACT(MONTH FROM i.fecha_emision)::int AS i_periodo,
EXTRACT(YEAR FROM e.fecha_emision)::int * 12 + EXTRACT(MONTH FROM e.fecha_emision)::int AS e_periodo
FROM cfdis i
JOIN cfdis e
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
WHERE i.cfdi_tipo_relacion = '07'
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
AND i.status NOT IN ('Cancelado','0')
AND e.tipo_comprobante = 'E'
AND e.status NOT IN ('Cancelado','0')
${rfcFilter}
ORDER BY i.fecha_emision DESC
`);
console.log(`Total pares: ${rows.length}`);
const buckets = { mismoMes: 0, eDespues1: 0, eDespuesMas: 0, eAntes: 0 };
for (const r of rows) {
const diff = Number(r.e_periodo) - Number(r.i_periodo);
if (diff < 0) buckets.eAntes++;
else if (diff === 0) buckets.mismoMes++;
else if (diff === 1) buckets.eDespues1++;
else buckets.eDespuesMas++;
}
console.log(` Mismo mes: ${buckets.mismoMes}`);
console.log(` E 1 mes después: ${buckets.eDespues1}`);
console.log(` E ≥2 meses después: ${buckets.eDespuesMas}`);
console.log(` E antes: ${buckets.eAntes}`);
if (rows.length > 0) {
console.log(`\n Detalle (top ${Math.min(rows.length, 10)}):`);
for (const r of rows.slice(0, 10)) {
const fi = new Date(r.i_fecha).toISOString().slice(0, 10);
const fe = new Date(r.e_fecha).toISOString().slice(0, 10);
const i_base = Number(r.i_total) - Number(r.i_iva || 0);
const e_base = Number(r.e_total) - Number(r.e_iva || 0);
const diff = Number(r.e_periodo) - Number(r.i_periodo);
console.log(` I/07 PPD ${r.i_uuid.substring(0,8)} ${fi} base=${i_base.toFixed(2)} ${r.i_emisor}${r.i_receptor} (${r.i_type})`);
console.log(` E/${r.e_rel ?? 'null'}/${r.e_mp || '?'} ${r.e_uuid.substring(0,8)} ${fe} base=${e_base.toFixed(2)} diffMeses=${diff} (${r.diff_dias}d)`);
}
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,12 @@
import { prisma, tenantDb } from '../src/config/database.js';
const prefix = process.argv[2];
async function main() {
const ts = await prisma.tenant.findMany({ where: { active: true }, select: { id: true, rfc: true, databaseName: true } });
for (const t of ts) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows } = await pool.query(`SELECT uuid FROM cfdis WHERE uuid LIKE $1 || '%'`, [prefix]);
for (const r of rows) console.log(t.rfc, r.uuid);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,104 @@
import { PrismaClient } from '@prisma/client';
import { readFileSync } from 'fs';
import { resolve } from 'path';
const prisma = new PrismaClient();
const SITUACIONES_VALIDAS = ['Definitivo', 'Presunto', 'Desvirtuado', 'Sentencia Favorable'];
function parseCsvLine(line: string): string[] {
const fields: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (c === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (c === ',' && !inQuotes) {
fields.push(current.trim());
current = '';
} else {
current += c;
}
}
fields.push(current.trim());
return fields;
}
async function main() {
const filePath = resolve(__dirname, '..', '..', '..', 'lista_negra', 'Listado_completo_69-B.csv');
console.log('📂 Leyendo:', filePath);
const data = readFileSync(filePath, 'latin1');
const lines = data.split('\n');
console.log(`📄 ${lines.length} líneas en el archivo`);
// Parsear registros (saltar headers: líneas 0, 1, 2)
const registros: { rfc: string; nombre: string; situacion: string }[] = [];
for (let i = 3; i < lines.length; i++) {
const line = lines[i].replace(/\r/g, '').trim();
if (!line) continue;
const fields = parseCsvLine(line);
if (fields.length < 4) continue;
const rfc = fields[1]?.trim();
const nombre = fields[2]?.trim();
const situacion = fields[3]?.trim();
if (!rfc || !rfc.match(/^[A-Z0-9&]{10,13}$/)) continue;
if (!SITUACIONES_VALIDAS.includes(situacion)) continue;
registros.push({ rfc, nombre, situacion });
}
console.log(`${registros.length} registros válidos parseados`);
// Contar por situación
const counts: Record<string, number> = {};
for (const r of registros) {
counts[r.situacion] = (counts[r.situacion] || 0) + 1;
}
console.log(' Situaciones:', counts);
// Sincronizar: limpiar y reinsertar todo
console.log('🔄 Sincronizando con base de datos...');
await prisma.listaNegra.deleteMany();
// Insertar en batches de 500
const BATCH = 500;
let inserted = 0;
for (let i = 0; i < registros.length; i += BATCH) {
const batch = registros.slice(i, i + BATCH);
// Deduplicar por RFC (quedarse con el último)
const unique = new Map<string, typeof batch[0]>();
for (const r of batch) unique.set(r.rfc, r);
await prisma.listaNegra.createMany({
data: Array.from(unique.values()),
skipDuplicates: true,
});
inserted += unique.size;
if ((i + BATCH) % 5000 === 0 || i + BATCH >= registros.length) {
console.log(` ${Math.min(i + BATCH, registros.length)}/${registros.length}...`);
}
}
const total = await prisma.listaNegra.count();
console.log(`\n🎉 Lista negra actualizada: ${total} registros en la base de datos`);
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,26 @@
import { prisma, tenantDb } from '../src/config/database.js';
const rawUuid = process.argv[2];
if (!rawUuid) { console.error('Usage: tsx scripts/inspect-cfdi-full.ts <uuid>'); process.exit(1); }
const uuid = rawUuid.toLowerCase();
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows } = await pool.query(
`SELECT * FROM cfdis WHERE LOWER(uuid) = $1`,
[uuid],
);
if (rows.length === 0) continue;
console.log(`\n[${t.rfc}] CFDI:`);
console.log(rows[0]);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,90 @@
/**
* Inspecciona el estado de un CFDI y sus relacionados (pagos + E/07) en todos
* los tenants. Útil para debug de saldos pendientes.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/inspect-cfdi.ts <uuid>
*/
import { prisma, tenantDb } from '../src/config/database.js';
const rawUuid = process.argv[2];
if (!rawUuid) {
console.error('Usage: tsx scripts/inspect-cfdi.ts <uuid>');
process.exit(1);
}
const uuid = rawUuid.toLowerCase();
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows: base } = await pool.query(
`SELECT id, uuid, type, tipo_comprobante, metodo_pago, status, fecha_emision,
total, total_mxn, monto_pago, monto_pago_mxn,
saldo_insoluto, saldo_pendiente, saldo_pendiente_mxn,
uuid_relacionado, cfdi_tipo_relacion, cfdis_relacionados,
rfc_emisor, rfc_receptor, conciliado, id_conciliacion,
source, facturapi_id
FROM cfdis WHERE LOWER(uuid) = $1`,
[uuid],
);
if (base.length === 0) continue;
console.log(`\n=== Tenant ${t.rfc} (${t.databaseName}) ===`);
console.log('CFDI base:');
console.log(base[0]);
// P complements que apuntan a este UUID via uuid_relacionado (DoctoRelacionado)
const { rows: pagosP } = await pool.query(
`SELECT id, uuid, type, tipo_comprobante, fecha_emision, fecha_pago_p,
monto_pago, monto_pago_mxn, num_parcialidad,
uuid_relacionado, status
FROM cfdis
WHERE tipo_comprobante = 'P' AND LOWER(uuid_relacionado) = $1
ORDER BY fecha_pago_p NULLS LAST, id`,
[uuid],
);
console.log(`\nComplementos P que referencian este UUID (DoctoRelacionado): ${pagosP.length}`);
for (const r of pagosP) console.log(' ', r);
// E CFDIs con cfdis_relacionados que contengan este UUID (TipoRelacion=07 típicamente)
const { rows: ecfdis } = await pool.query(
`SELECT id, uuid, type, tipo_comprobante, metodo_pago, fecha_emision,
total, total_mxn, cfdi_tipo_relacion, cfdis_relacionados,
status
FROM cfdis
WHERE tipo_comprobante = 'E'
AND cfdis_relacionados IS NOT NULL
AND LOWER(cfdis_relacionados) LIKE $1
ORDER BY fecha_emision, id`,
[`%${uuid}%`],
);
console.log(`\nCFDIs tipo E con este UUID en cfdis_relacionados: ${ecfdis.length}`);
for (const r of ecfdis) console.log(' ', r);
// Si el base está conciliado, traer la fila
if (base[0].id_conciliacion) {
const { rows: conc } = await pool.query(
`SELECT * FROM conciliaciones WHERE id = $1`,
[base[0].id_conciliacion],
);
console.log(`\nConciliación vinculada:`);
for (const r of conc) console.log(' ', r);
}
}
await prisma.$disconnect();
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,66 @@
/**
* Inspect the shape of the response from Facturapi invoices.retrieve
* for a recent emission, to know what fields are actually populated.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { env } from '../src/config/env.js';
const CONTRIB_ID = '414b22a8-c6e2-4f39-be0f-7537a848107e';
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
const INVOICE_ID = '69ebc61f87f122486514c3b4'; // latest
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC },
select: { id: true, databaseName: true },
});
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
// Fetch org API key
const { rows } = await pool.query<{ facturapi_org_id: string }>(
`SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id=$1 AND active=true`,
[CONTRIB_ID],
);
if (rows.length === 0) {
console.log('No facturapi_org_id found');
return;
}
const orgId = rows[0].facturapi_org_id;
// Get the org's API key (HTTP direct because SDK has issues)
const userKey = env.FACTURAPI_USER_KEY;
const keyRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
headers: { Authorization: `Bearer ${userKey}` },
});
const keyData = await keyRes.json();
const apiKey = typeof keyData === 'string' ? keyData : keyData.apikey || keyData.key;
// Retrieve the invoice
const invRes = await fetch(`https://www.facturapi.io/v2/invoices/${INVOICE_ID}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const invoice = await invRes.json();
console.log('=== FACTURAPI INVOICE RESPONSE ===');
console.log('Top-level keys:', Object.keys(invoice).sort().join(', '));
console.log('');
console.log('invoice.id =', invoice.id);
console.log('invoice.uuid =', invoice.uuid);
console.log('invoice.date =', invoice.date);
console.log('invoice.subtotal =', invoice.subtotal);
console.log('invoice.total =', invoice.total);
console.log('invoice.series =', invoice.series);
console.log('invoice.folio_number =', invoice.folio_number);
console.log('invoice.issuer =', JSON.stringify(invoice.issuer, null, 2));
console.log('invoice.issuer_info =', JSON.stringify(invoice.issuer_info, null, 2));
console.log('invoice.issuer_type =', invoice.issuer_type);
console.log('invoice.organization =', JSON.stringify(invoice.organization, null, 2));
console.log('invoice.customer =', JSON.stringify(invoice.customer, null, 2));
console.log('invoice.taxes =', JSON.stringify(invoice.taxes, null, 2));
console.log('invoice.items =', JSON.stringify(invoice.items?.slice(0, 2), null, 2));
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,41 @@
import { prisma, tenantDb } from '../src/config/database.js';
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC },
select: { id: true, databaseName: true },
});
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
// Get the full latest Facturapi CFDI with ALL fields
const { rows } = await pool.query(
`SELECT * FROM cfdis
WHERE source = 'facturapi'
ORDER BY fecha_emision DESC
LIMIT 1`,
);
if (rows.length === 0) {
console.log('No hay CFDIs Facturapi');
return;
}
const r = rows[0];
console.log('UUID:', r.uuid);
console.log('');
console.log('Campos relevantes de emisor/receptor:');
const keys = Object.keys(r).sort();
for (const k of keys) {
if (/emisor|receptor|regimen|contribuyente|type|tipo|facturapi|uso_cfdi|forma|metodo|total|iva|lugar|fecha|status|version|uuid|id|source|serie|folio|xml_original/i.test(k)) {
const v = r[k];
const val = typeof v === 'string' && v.length > 200 ? v.substring(0, 200) + '…' : v;
console.log(` ${k} = ${val instanceof Date ? val.toISOString() : String(val).substring(0, 200)}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,52 @@
import { prisma, tenantDb } from '../src/config/database.js';
const I_UUID = '5c874749-748f-11f0-96b1-2b9310891836';
const E_UUID = '7163da3b-748f-11f0-9853-e97a8e1dedd9';
async function main() {
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
for (const t of tenants) {
let pool;
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
const { rows } = await pool.query(
`SELECT uuid, tipo_comprobante, metodo_pago, cfdi_tipo_relacion, cfdis_relacionados,
status, fecha_emision, total_mxn, iva_traslado_mxn,
rfc_emisor, rfc_receptor, contribuyente_id, type
FROM cfdis WHERE LOWER(uuid) IN (LOWER($1), LOWER($2))`,
[I_UUID, E_UUID],
);
if (rows.length === 0) continue;
console.log(`\n=== ${t.rfc} ===`);
for (const r of rows) {
const fe = new Date(r.fecha_emision).toISOString().slice(0, 10);
console.log(`\n UUID: ${r.uuid}`);
console.log(` tipo: ${r.tipo_comprobante}/${r.metodo_pago || '?'} rel=${r.cfdi_tipo_relacion ?? 'null'} status=${r.status} type=${r.type}`);
console.log(` fecha: ${fe} total=${r.total_mxn} IVA=${r.iva_traslado_mxn}`);
console.log(` ${r.rfc_emisor}${r.rfc_receptor} contrib_id=${r.contribuyente_id}`);
console.log(` cfdis_relacionados: ${r.cfdis_relacionados ?? 'NULL'}`);
}
// Si están ambos, verificar match de cfdis_relacionados
if (rows.length === 2) {
const i = rows.find((x: any) => x.uuid.toLowerCase() === I_UUID.toLowerCase());
const e = rows.find((x: any) => x.uuid.toLowerCase() === E_UUID.toLowerCase());
if (i && e) {
const iRels = (i.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean);
const eRels = (e.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean);
const overlap = iRels.filter((u: string) => eRels.includes(u));
console.log(`\n I refs (${iRels.length}): ${iRels.join(', ').substring(0, 200)}`);
console.log(` E refs (${eRels.length}): ${eRels.join(', ').substring(0, 200)}`);
console.log(` Overlap (${overlap.length}): ${overlap.join(', ')}`);
// Cruz: ¿la E referencia a la I directamente, o viceversa?
if (eRels.includes(I_UUID.toLowerCase())) console.log(` → E.cfdis_relacionados INCLUYE el UUID de I/07 PPD`);
if (iRels.includes(E_UUID.toLowerCase())) console.log(` → I.cfdis_relacionados INCLUYE el UUID de E`);
}
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,48 @@
import { prisma, tenantDb } from '../src/config/database.js';
const rawRfc = process.argv[2];
if (!rawRfc) {
console.error('Usage: tsx scripts/inspect-rfc.ts <rfc>');
process.exit(1);
}
const rfc = rawRfc.toUpperCase();
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows: contrib } = await pool.query(
`SELECT * FROM contribuyentes WHERE UPPER(rfc) = $1`,
[rfc],
);
if (contrib.length > 0) {
console.log(`\n[${t.rfc}] Contribuyente ${rfc}:`);
console.log(contrib[0]);
}
const { rows: rfcEntry } = await pool.query(
`SELECT id, rfc, razon_social, regimen_fiscal, codigo_postal FROM rfcs WHERE UPPER(rfc) = $1`,
[rfc],
);
if (rfcEntry.length > 0) {
console.log(`[${t.rfc}] rfcs table:`, rfcEntry[0]);
}
if (contrib.length > 0) {
const { rows: org } = await pool.query(
`SELECT facturapi_org_id, csd_uploaded, active FROM facturapi_orgs WHERE contribuyente_id = $1`,
[contrib[0].entidad_id],
);
if (org.length > 0) console.log(`[${t.rfc}] facturapi_orgs:`, org[0]);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,159 @@
/**
* Invalida TODAS las entradas en `metricas_mensuales` — marca para recompute
* cada (contribuyente_id, anio, mes) que tenga datos cacheados. Diseñado para
* usarse después de un cambio de fórmula que afecta resultados históricos
* (ej. 2026-04-23: NC tipo E con TipoRelacion=07 dejan de restar en Grupo 1).
*
* El cron `metricas-invalidations.job` (cada 15min) procesa el backlog.
* Para acelerar: `pnpm --filter @horux/api exec tsx -e "import { runProcessInvalidations } from './src/jobs/metricas-invalidations.job.js'; runProcessInvalidations().then(()=>process.exit(0))"`
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.ts --dry # reporta sin escribir
*/
import { prisma, tenantDb } from '../src/config/database.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
const REASON = process.argv.find(a => a.startsWith('--reason='))?.slice(9) || 'FORMULA_CHANGE_E07_GRUPO1';
interface PerTenantResult {
tenantId: string;
rfc: string;
databaseName: string;
metricasRows: number;
marcadasNuevas: number;
marcadasUpdate: number;
error?: string;
}
async function invalidateTenant(
tenantId: string,
rfc: string,
databaseName: string,
): Promise<PerTenantResult> {
const result: PerTenantResult = {
tenantId,
rfc,
databaseName,
metricasRows: 0,
marcadasNuevas: 0,
marcadasUpdate: 0,
};
const pool = await tenantDb.getPool(tenantId, databaseName);
// Cuenta filas existentes en metricas_mensuales para reportar
const { rows: cnt } = await pool.query<{ n: number }>(
`SELECT COUNT(DISTINCT (contribuyente_id, anio, mes))::int AS n FROM metricas_mensuales`,
);
result.metricasRows = cnt[0]?.n || 0;
if (result.metricasRows === 0) return result;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Insert-or-update: si ya estaba marcada, sobrescribe reason y marcado_at
// para que el cron la re-procese con el motivo correcto.
const { rows: inserted } = await client.query<{
contribuyente_id: string;
anio: number;
mes: number;
was_new: boolean;
}>(
`
INSERT INTO metricas_invalidaciones (contribuyente_id, anio, mes, reason)
SELECT DISTINCT contribuyente_id, anio, mes, $1 AS reason
FROM metricas_mensuales
ON CONFLICT (contribuyente_id, anio, mes) DO UPDATE
SET reason = EXCLUDED.reason, marcado_at = now()
RETURNING contribuyente_id, anio, mes, (xmax = 0) AS was_new
`,
[REASON],
);
result.marcadasNuevas = inserted.filter(r => r.was_new).length;
result.marcadasUpdate = inserted.length - result.marcadasNuevas;
if (DRY_RUN) {
await client.query('ROLLBACK');
} else {
await client.query('COMMIT');
}
} catch (err: any) {
await client.query('ROLLBACK').catch(() => {});
result.error = err?.message || String(err);
} finally {
client.release();
}
return result;
}
async function main() {
console.log(`=== Invalidate metricas_mensuales ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===`);
console.log(`Reason: ${REASON}\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
const results: PerTenantResult[] = [];
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] (${t.databaseName}) ... `);
try {
const r = await invalidateTenant(t.id, t.rfc, t.databaseName);
results.push(r);
if (r.error) {
console.log(`ERROR: ${r.error}`);
} else if (r.metricasRows === 0) {
console.log(`sin cache (skip)`);
} else {
console.log(
`cache=${r.metricasRows} (contrib,año,mes), marcadas=${r.marcadasNuevas + r.marcadasUpdate} (nuevas=${r.marcadasNuevas}, re-marcadas=${r.marcadasUpdate})`,
);
}
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
results.push({
tenantId: t.id,
rfc: t.rfc,
databaseName: t.databaseName,
metricasRows: 0,
marcadasNuevas: 0,
marcadasUpdate: 0,
error: err?.message || String(err),
});
}
}
const totalMetricas = results.reduce((s, r) => s + r.metricasRows, 0);
const totalMarcadas = results.reduce((s, r) => s + r.marcadasNuevas + r.marcadasUpdate, 0);
const tenantsTouched = results.filter(r => r.marcadasNuevas + r.marcadasUpdate > 0).length;
const tenantsFailed = results.filter(r => r.error).length;
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${results.length}`);
console.log(` Tenants con cache: ${tenantsTouched}`);
console.log(` Filas cache total: ${totalMetricas}`);
console.log(` Invalidaciones: ${totalMarcadas}${DRY_RUN ? ' (rolled back)' : ''}`);
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
if (!DRY_RUN && totalMarcadas > 0) {
console.log(`\nCron metricas-invalidations procesará el backlog en <=15 min.`);
console.log(`Para disparar manual: runProcessInvalidations() desde un tsx -e ad-hoc.`);
}
await prisma.$disconnect();
process.exit(tenantsFailed > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,26 @@
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenants = await prisma.tenant.findMany({ where: { active: true }, select: { id: true, rfc: true, databaseName: true } });
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
// descubrir tablas con 'entidad' o 'contribuyente' en el nombre
const { rows: tbls } = await pool.query(`SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND (table_name LIKE '%entidad%' OR table_name LIKE '%contribuyente%') ORDER BY table_name`);
console.log(`\n[${t.rfc}] tablas:`, tbls.map((r: any) => r.table_name).join(', '));
// Join con rfcs si existe
try {
const { rows } = await pool.query(
`SELECT c.entidad_id, c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal
FROM contribuyentes c
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
ORDER BY r.razon_social NULLS LAST, c.rfc`,
);
for (const r of rows) console.log(' ', r);
} catch (e: any) {
console.log(' ERR:', e.message);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,33 @@
/**
* Eager tenant migration script.
* Run: pnpm --filter @horux/api db:migrate-tenants
* Or: pnpm db:migrate-tenants (from monorepo root via Turborepo)
*
* Applies pending SQL migrations to all active tenant databases.
*/
import { migrateAll } from '../src/config/tenant-migrations.js';
async function main() {
console.log('=== Tenant Schema Migration (Eager) ===\n');
const start = Date.now();
const result = await migrateAll();
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n=== Done in ${elapsed}s ===`);
console.log(` Migrated: ${result.success}`);
console.log(` Up-to-date: ${result.skipped}`);
console.log(` Failed: ${result.failed}`);
if (result.failed > 0) {
console.error('\nSome tenants failed migration. Check logs above.');
process.exit(1);
}
process.exit(0);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,27 @@
process.env.METRICAS_BYPASS_CACHE = '1';
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
const yearMonth = process.argv[4] || '2025-05';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const r = await calcularIngresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId);
console.log(`\n=== Ingresos ${yearMonth} contrib=${contribuyenteId} (BYPASS_CACHE=1) ===`);
console.log(`Total: ${r.total.toFixed(2)}`);
for (const p of r.porRegimen) {
console.log(` ${p.regimenClave} (${p.regimenDescripcion}): ${p.monto.toFixed(2)}`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* Genera los 8 templates de email como archivos HTML estáticos en
* `apps/api/email-previews/` para revisar el diseño en el navegador
* sin necesidad de SMTP configurado.
*
* Uso:
* pnpm email:preview
*
* Tras correr, abre `apps/api/email-previews/index.html` para ver
* el listado con links a cada template.
*/
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const OUT_DIR = resolve(ROOT, 'email-previews');
// Datos de ejemplo realistas para cada template
const SAMPLES = {
'welcome.html': {
label: 'Bienvenida',
fixture: { nombre: 'Carlos Hernández', email: 'carlos@empresa.com', tempPassword: 'a3f2c891' },
importPath: '../src/services/email/templates/welcome.ts',
fnName: 'welcomeEmail',
},
'password-reset.html': {
label: 'Recuperación de contraseña',
fixture: { nombre: 'Carlos Hernández', resetUrl: 'https://horuxfin.com/reset-password?token=a8e4f...' },
importPath: '../src/services/email/templates/password-reset.ts',
fnName: 'passwordResetEmail',
},
'payment-confirmed.html': {
label: 'Pago confirmado',
fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA', date: new Date().toLocaleDateString('es-MX') },
importPath: '../src/services/email/templates/payment-confirmed.ts',
fnName: 'paymentConfirmedEmail',
},
'payment-failed.html': {
label: 'Pago rechazado',
fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA' },
importPath: '../src/services/email/templates/payment-failed.ts',
fnName: 'paymentFailedEmail',
},
'subscription-cancelled.html': {
label: 'Suscripción cancelada',
fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA' },
importPath: '../src/services/email/templates/subscription-cancelled.ts',
fnName: 'subscriptionCancelledEmail',
},
'subscription-expiring.html': {
label: 'Suscripción por vencer',
fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA', expiresAt: '15 de mayo, 2026' },
importPath: '../src/services/email/templates/subscription-expiring.ts',
fnName: 'subscriptionExpiringEmail',
},
'fiel-notification.html': {
label: 'e.firma cargada (admin)',
fixture: { clienteNombre: 'Empresa Demo SA de CV', clienteRfc: 'EDE123456AB1' },
importPath: '../src/services/email/templates/fiel-notification.ts',
fnName: 'fielNotificationEmail',
},
'weekly-update.html': {
label: 'Actualización semanal',
fixture: {
nombre: 'Carlos Hernández',
empresa: 'Empresa Demo SA de CV',
periodoLabel: 'Abril 2026',
kpis: {
ingresos: 285430.50,
egresos: 142900.00,
utilidad: 142530.50,
margen: 49.9,
ivaBalance: 18420.00,
ivaAFavorAcumulado: 32100.00,
cfdisEmitidos: 47,
cfdisRecibidos: 23,
},
alertas: [
{ titulo: 'Cliente en lista negra', mensaje: '1 cliente con situación SAT "Definitivo".', prioridad: 'alta' },
{ titulo: 'Concentración alta de proveedores', mensaje: 'IHH = 6,840. Más del 50% del gasto en 1 proveedor.', prioridad: 'media' },
{ titulo: 'Pago en efectivo', mensaje: '3 facturas recibidas con forma de pago "01-Efectivo" este mes.', prioridad: 'baja' },
],
discrepanciasPorMes: [
{ label: 'Abril 2026', count: 2 },
{ label: 'Marzo 2026', count: 5 },
{ label: 'Febrero 2026', count: 0 },
{ label: 'Enero 2026', count: 1 },
],
fechaGeneracion: new Date().toLocaleString('es-MX', { dateStyle: 'long', timeStyle: 'short' }),
},
importPath: '../src/services/email/templates/weekly-update.ts',
fnName: 'weeklyUpdateEmail',
},
'new-client-admin.html': {
label: 'Nuevo cliente registrado (admin)',
fixture: {
clienteNombre: 'Empresa Demo SA de CV',
clienteRfc: 'EDE123456AB1',
adminEmail: 'admin@empresademo.com',
adminNombre: 'Carlos Hernández',
tempPassword: 'a3f2c891',
databaseName: 'horux_ede123456ab1',
plan: 'business_ia',
},
importPath: '../src/services/email/templates/new-client-admin.ts',
fnName: 'newClientAdminEmail',
},
};
async function main() {
// Limpia output previo y recrea
try { rmSync(OUT_DIR, { recursive: true, force: true }); } catch {}
mkdirSync(OUT_DIR, { recursive: true });
const generated = [];
for (const [filename, sample] of Object.entries(SAMPLES)) {
const modPath = resolve(__dirname, sample.importPath);
const mod = await import(pathToFileURL(modPath).href);
const fn = mod[sample.fnName];
if (typeof fn !== 'function') {
console.error(`[email:preview] FAIL: ${sample.fnName} no exportada en ${modPath}`);
continue;
}
const html = fn(sample.fixture);
const outPath = resolve(OUT_DIR, filename);
writeFileSync(outPath, html, 'utf8');
generated.push({ filename, label: sample.label });
console.log(`[email:preview] ✓ ${filename}`);
}
// Index navegable
const indexHtml = `<!DOCTYPE html>
<html lang="es"><head><meta charset="utf-8"><title>Email previews — Horux 360</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 720px; margin: 40px auto; padding: 0 24px; color: #1E293B; }
h1 { font-size: 24px; margin-bottom: 8px; }
p.muted { color: #64748B; margin-top: 0; }
ul { list-style: none; padding: 0; }
li { margin: 8px 0; padding: 14px 18px; border: 1px solid #E2E8F0; border-radius: 8px; }
li:hover { background: #F8FAFC; }
a { color: #2563EB; text-decoration: none; font-weight: 500; }
a:hover { text-decoration: underline; }
small { color: #94A3B8; font-size: 12px; margin-left: 8px; }
</style></head><body>
<h1>Email previews — Horux 360</h1>
<p class="muted">Generados desde los templates en <code>apps/api/src/services/email/templates/</code> con datos de ejemplo. Cada link abre el HTML renderizado tal como llegaría al inbox del cliente.</p>
<ul>
${generated.map(g => `<li><a href="${g.filename}">${g.label}</a> <small>(${g.filename})</small></li>`).join('\n ')}
</ul>
<p class="muted" style="margin-top:32px;font-size:13px;">Si modificas un template, vuelve a correr <code>pnpm email:preview</code> para regenerar.</p>
</body></html>`;
writeFileSync(resolve(OUT_DIR, 'index.html'), indexHtml, 'utf8');
console.log(`\n[email:preview] ${generated.length} templates generados.`);
console.log(`[email:preview] Abre: ${resolve(OUT_DIR, 'index.html')}`);
}
main().catch(err => {
console.error('[email:preview] FAIL:', err);
process.exit(1);
});

View File

@@ -0,0 +1,32 @@
/**
* Dispara manualmente el procesamiento de `metricas_invalidaciones` para todos
* los tenants. Útil tras un `invalidate-metricas-all.ts` para no esperar al
* cron (cada 15 min).
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/process-metricas-now.ts
*/
import { prisma } from '../src/config/database.js';
import { processAllTenantsInvalidations } from '../src/services/metricas-compute.service.js';
async function main() {
console.log('=== Procesar metricas_invalidaciones (all tenants) ===\n');
const start = Date.now();
const r = await processAllTenantsInvalidations();
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(
`\nTenants revisados: ${r.tenantsRevisados}\n` +
`Invalidaciones procesadas: ${r.totalProcesadas}\n` +
`Filas metricas_mensuales escritas: ${r.totalFilasEscritas}\n` +
`Errores: ${r.totalErrores}\n` +
`Tiempo: ${elapsed}s`,
);
await prisma.$disconnect();
process.exit(r.totalErrores > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,71 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('Setting up horux_despachos database...');
// Create admin user
const hash = await bcrypt.hash('Admin12345!', 12);
const user = await prisma.user.upsert({
where: { email: 'ivan@horuxfin.com' },
update: {},
create: {
email: 'ivan@horuxfin.com',
passwordHash: hash,
nombre: 'Ivan Admin',
},
});
console.log('✅ User created:', user.email);
// Find or create tenant
let tenant = await prisma.tenant.findFirst();
if (!tenant) {
tenant = await prisma.tenant.create({
data: {
nombre: 'Despacho Demo',
rfc: 'DDE250101AAA',
plan: 'business',
databaseName: 'horux_dde250101aaa',
verticalProfile: 'CONTABLE',
dbMode: 'MANAGED',
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
console.log('✅ Tenant created:', tenant.nombre);
} else {
console.log('✅ Tenant exists:', tenant.nombre);
}
// Create membership
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
update: {},
create: {
userId: user.id,
tenantId: tenant.id,
rolId: 1,
isOwner: true,
},
});
console.log('✅ Membership created (owner)');
// Set lastTenantId
await prisma.user.update({
where: { id: user.id },
data: { lastTenantId: tenant.id },
});
console.log('\n🎉 Setup complete!');
console.log('Login: ivan@horuxfin.com / Admin12345!');
console.log('Tenant:', tenant.nombre, `(${tenant.rfc})`);
}
main()
.catch((e) => {
console.error('Setup failed:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,47 @@
/**
* CLI wrapper del watchdog. La lógica vive en
* `src/services/sat/sweep-stale-jobs.service.ts` para que también se pueda
* correr desde un cron (`sat-sync.job.ts`) sin duplicar código.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts # dry-run
* pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts --apply # ejecuta
* STALE_RUNNING_HOURS=2 pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts
*/
import { prisma } from '../src/config/database.js';
import { sweepStaleSatJobs } from '../src/services/sat/sweep-stale-jobs.service.js';
async function main() {
const apply = process.argv.includes('--apply');
const pendingHours = Number(process.env.STALE_PENDING_HOURS || 12);
const runningHours = Number(process.env.STALE_RUNNING_HOURS || 4);
const mode = apply ? 'APPLY' : 'DRY-RUN';
console.log(`=== SAT stale-jobs watchdog [${mode}] ===`);
console.log(` pending: nextRetryAt < now ${pendingHours}h`);
console.log(` running: startedAt < now ${runningHours}h`);
console.log();
const result = await sweepStaleSatJobs({ apply, pendingHours, runningHours });
console.log(`Encontrados:`);
console.log(` pending stale: ${result.pendingFound}`);
console.log(` running stale: ${result.runningFound}`);
for (const e of result.entries) {
console.log(`${e.id} tenant=${e.tenantId} kind=${e.kind} edad=${e.ageHours}h`);
}
if (!apply) {
console.log(`\n[DRY-RUN] No se aplicaron cambios. Pasa --apply para marcar como failed.`);
} else {
console.log(`\nMarcados como failed: pending=${result.pendingMarked} running=${result.runningMarked}`);
}
await prisma.$disconnect();
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,96 @@
import { emailService } from '../src/services/email/email.service.js';
const recipients = ['ivan@horuxfin.com', 'carlos@horuxfin.com'];
async function sendAllSamples() {
for (const to of recipients) {
console.log(`\n=== Enviando a ${to} ===`);
// 1. Welcome
console.log('1/6 Bienvenida...');
await emailService.sendWelcome(to, {
nombre: 'Ivan Alcaraz',
email: 'ivan@horuxfin.com',
tempPassword: 'TempPass123!',
});
// 2. FIEL notification (goes to ADMIN_EMAIL, but we override for test)
console.log('2/6 Notificación FIEL...');
// Send directly since sendFielNotification goes to admin
const { fielNotificationEmail } = await import('../src/services/email/templates/fiel-notification.js');
const { createTransport } = await import('nodemailer');
const { env } = await import('../src/config/env.js');
const transport = createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT),
secure: false,
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
});
const fielHtml = fielNotificationEmail({
clienteNombre: 'Horux 360',
clienteRfc: 'CAS200101XXX',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: '[Horux 360] subió su FIEL (MUESTRA)',
html: fielHtml,
});
// 3. Payment confirmed
console.log('3/6 Pago confirmado...');
await emailService.sendPaymentConfirmed(to, {
nombre: 'Ivan Alcaraz',
amount: 1499,
plan: 'Enterprise',
date: '16 de marzo de 2026',
});
// 4. Payment failed
console.log('4/6 Pago fallido...');
const { paymentFailedEmail } = await import('../src/services/email/templates/payment-failed.js');
const failedHtml = paymentFailedEmail({
nombre: 'Ivan Alcaraz',
amount: 1499,
plan: 'Enterprise',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: 'Problema con tu pago - Horux360 (MUESTRA)',
html: failedHtml,
});
// 5. Subscription expiring
console.log('5/6 Suscripción por vencer...');
await emailService.sendSubscriptionExpiring(to, {
nombre: 'Ivan Alcaraz',
plan: 'Enterprise',
expiresAt: '21 de marzo de 2026',
});
// 6. Subscription cancelled
console.log('6/6 Suscripción cancelada...');
const { subscriptionCancelledEmail } = await import('../src/services/email/templates/subscription-cancelled.js');
const cancelledHtml = subscriptionCancelledEmail({
nombre: 'Ivan Alcaraz',
plan: 'Enterprise',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: 'Suscripción cancelada - Horux360 (MUESTRA)',
html: cancelledHtml,
});
console.log(`Listo: 6 correos enviados a ${to}`);
}
console.log('\n=== Todos los correos enviados ===');
process.exit(0);
}
sendAllSamples().catch((err) => {
console.error('Error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,97 @@
/**
* Valida la alineación dashboard ≡ impuestos tras refactor de getResumenIva.
* Para 5 muestras aleatorias por contribuyente, compara:
* dashboard.calcularIvaBalancePorRegimen().total vs
* impuestos.getResumenIva().resultado
*
* Deben coincidir céntimo por céntimo (Resultado = Trasladado Acreditable Retenido,
* usando los mismos 6 buckets del dashboard).
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts
* METRICAS_BYPASS_CACHE=1 pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts
*/
import { prisma, tenantDb } from '../src/config/database.js';
import * as dashboard from '../src/services/dashboard.service.js';
import { getResumenIva } from '../src/services/impuestos.service.js';
const TOL = 0.01;
function cmp(a: number, b: number): boolean { return Math.abs(a - b) <= TOL; }
function fmt(n: number): string {
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
async function main() {
console.log('=== Validación dashboard.balance ≡ impuestos.resultado ===');
console.log(` BYPASS_CACHE=${process.env.METRICAS_BYPASS_CACHE === '1' ? 'YES' : 'no'}\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
let total = 0;
let pass = 0;
let fail = 0;
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows: contribs } = await pool.query<{ entidad_id: string; nombre: string }>(
`SELECT c.entidad_id, eg.nombre
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE EXISTS (SELECT 1 FROM metricas_mensuales m WHERE m.contribuyente_id = c.entidad_id)`,
);
if (contribs.length === 0) continue;
console.log(`[${t.rfc}] ${contribs.length} contribuyentes`);
for (const c of contribs) {
const { rows: samples } = await pool.query<{ anio: number; mes: number }>(
`SELECT anio, mes FROM (
SELECT DISTINCT anio, mes FROM metricas_mensuales WHERE contribuyente_id = $1
) t
ORDER BY random() LIMIT 5`,
[c.entidad_id],
);
console.log(` ${c.nombre}:`);
for (const s of samples) {
total++;
const fi = `${s.anio}-${String(s.mes).padStart(2, '0')}-01`;
const lastDay = new Date(s.anio, s.mes, 0).getDate();
const ff = `${s.anio}-${String(s.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const bal = await dashboard.calcularIvaBalancePorRegimen(
pool, t.id, fi, ff, [], undefined, false, c.entidad_id,
);
const resumen = await getResumenIva(pool, fi, ff, t.id, false, c.entidad_id);
const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`;
if (cmp(bal.total, resumen.resultado)) {
pass++;
console.log(`${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)}`);
} else {
fail++;
const delta = bal.total - resumen.resultado;
console.log(`${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)} Δ=$${fmt(delta)}`);
console.log(` T=$${fmt(resumen.trasladado)} A=$${fmt(resumen.acreditable)} R=$${fmt(resumen.retenido)}`);
}
}
}
}
console.log(`\n=== Resumen ===`);
console.log(` Muestras: ${total}`);
console.log(` PASS: ${pass}`);
console.log(` FAIL: ${fail}`);
await prisma.$disconnect();
process.exit(fail > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,115 @@
/**
* Compara Gastos del Dashboard vs Drill-down para un mes/contribuyente.
* Identifica discrepancias y rompe el detalle por lado (factura/pago/NC).
*
* Uso: tsx scripts/validate-gastos.ts <tenantRfc> <entidadId> <añoMes>
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
const tenantRfcArg = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
const yearMonth = process.argv[4] || '2025-02';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: tenantRfcArg, active: true },
select: { id: true, rfc: true, databaseName: true },
});
if (!tenant) { console.error('Tenant not found'); process.exit(1); }
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
console.log(`\n=== Contribuyente ${contribuyenteId}${fi} a ${ff} ===\n`);
// 1. Dashboard (calcularEgresosPorRegimen)
const dashboard = await calcularEgresosPorRegimen(
pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId,
);
console.log('DASHBOARD calcularEgresosPorRegimen:');
console.log(` total: ${dashboard.total.toFixed(2)}`);
for (const r of dashboard.porRegimen) {
console.log(` ${r.regimenClave} (${r.regimenDescripcion}): ${r.monto.toFixed(2)}`);
}
// 2. Drill-down query (simulated — bucket=gastos uniforme)
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
// bucket=gastos: RECIBIDO I PUE + RECIBIDO P + RECIBIDO E PUE (excl 07)
// Sumamos tomando en cuenta el signo (E resta)
const { rows: drillRows } = await pool.query(
`SELECT
type, tipo_comprobante, metodo_pago,
COALESCE(cfdi_tipo_relacion, '') AS tipo_rel,
COUNT(*)::int AS n,
SUM(total_mxn) AS total_bruto,
SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})) AS total_neto,
SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})) AS pago_neto
FROM cfdis
WHERE (
(type = 'RECIBIDO' AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
AND COALESCE(cfdi_tipo_relacion, '') <> '07')
OR (type = 'RECIBIDO' AND tipo_comprobante = 'P')
OR (type = 'RECIBIDO' AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND COALESCE(cfdi_tipo_relacion, '') <> '07')
)
AND regimen_fiscal_receptor IN ('605','606','612','621','625','626','601','603','607','608','610','611','614','615','620','622','623','624')
AND status NOT IN ('Cancelado','0')
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
OR (tipo_comprobante!='P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
AND contribuyente_id = $3
GROUP BY type, tipo_comprobante, metodo_pago, tipo_rel
ORDER BY tipo_comprobante, metodo_pago`,
[fi, ff, contribuyenteId],
);
console.log(`\nDRILL-DOWN bucket=gastos (filas del drill por bucket):`);
let drillSumaFacturas = 0, drillSumaPagos = 0, drillSumaNC = 0;
for (const r of drillRows) {
const tc = r.tipo_comprobante;
const valor = tc === 'P' ? Number(r.pago_neto) : Number(r.total_neto);
console.log(` ${r.type} ${tc} ${r.metodo_pago || '-'} rel=${r.tipo_rel || '-'} n=${r.n} total_bruto=${Number(r.total_bruto).toFixed(2)} valor_neto=${valor.toFixed(2)}`);
if (tc === 'I') drillSumaFacturas += valor;
else if (tc === 'P') drillSumaPagos += valor;
else if (tc === 'E') drillSumaNC += valor;
}
const drillTotal = drillSumaFacturas + drillSumaPagos - drillSumaNC;
console.log(` → facturas=${drillSumaFacturas.toFixed(2)} pagos=${drillSumaPagos.toFixed(2)} NC=${drillSumaNC.toFixed(2)}`);
console.log(` → drill total = ${drillTotal.toFixed(2)}`);
// 3. Comparación
const delta = dashboard.total - drillTotal;
console.log(`\n=== COMPARATIVA ===`);
console.log(` Dashboard: ${dashboard.total.toFixed(2)}`);
console.log(` Drill-down: ${drillTotal.toFixed(2)}`);
console.log(` Delta: ${delta.toFixed(2)}`);
if (Math.abs(delta) < 0.01) {
console.log(` ✓ CUADRAN`);
} else {
console.log(` ✗ NO CUADRAN — investigar`);
}
// 4. Régimenes del receptor que aparecen vs los ignorados
const { rows: regsReceptor } = await pool.query(
`SELECT DISTINCT regimen_fiscal_receptor
FROM cfdis
WHERE contribuyente_id = $1
AND type = 'RECIBIDO'
AND fecha_emision >= $2::date AND fecha_emision < ($3::date + interval '1 day')
ORDER BY regimen_fiscal_receptor`,
[contribuyenteId, fi, ff],
);
console.log(`\nRegímenes en CFDIs RECIBIDOS del periodo:`, regsReceptor.map(r => r.regimen_fiscal_receptor).join(', '));
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,39 @@
/**
* Paridad dashboard vs drill para INGRESOS de un contribuyente en un año.
* Similar a validate-gastos pero para el lado emisor.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || '414b22a8-c6e2-4f39-be0f-7537a848107e';
const año = Number(process.argv[4] || '2025');
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) { console.error('Tenant not found'); process.exit(1); }
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
console.log(`\n=== Ingresos ${año} Contribuyente ${contribuyenteId} ===\n`);
console.log(`mes | total por régimen | total mes`);
let totalAño = 0;
for (let m = 1; m <= 12; m++) {
const lastDay = new Date(año, m, 0).getDate();
const mm = String(m).padStart(2, '0');
const fi = `${año}-${mm}-01`;
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
const ingresos = await calcularIngresosPorRegimen(
pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId,
);
const porReg = ingresos.porRegimen.map(r => `${r.regimenClave}:${r.monto.toFixed(2)}`).join(' / ');
console.log(`${mm} | ${porReg || '(sin datos)'} | ${ingresos.total.toFixed(2)}`);
totalAño += ingresos.total;
}
console.log(`\nTotal año: ${totalAño.toFixed(2)}`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,160 @@
/**
* Validación Tanda A: para cada contribuyente con datos en metricas_mensuales,
* toma 5 filas al azar y compara contra el cálculo on-the-fly usando los
* servicios canónicos (dashboard, impuestos). Reporta PASS/FAIL por celda.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/validate-metricas.ts
*/
import { prisma, tenantDb } from '../src/config/database.js';
import {
calcularIngresosPorRegimen,
calcularEgresosPorRegimen,
} from '../src/services/dashboard.service.js';
import { getResumenIva } from '../src/services/impuestos.service.js';
const TOL = 0.01; // tolerancia de $0.01 para redondeo decimal
interface StoredRow {
contribuyente_id: string;
anio: number;
mes: number;
regimen_fiscal: string | null;
ingresos_cobrados: string;
egresos_pagados: string;
iva_trasladado_total: string;
iva_acreditable: string;
iva_retenido_cobrado: string;
iva_resultado: string;
cfdis_emitidos_count: number;
cfdis_recibidos_count: number;
cfdis_cancelados_count: number;
}
function cmp(a: number, b: number): boolean {
return Math.abs(a - b) <= TOL;
}
function fmt(n: number): string {
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
async function validateRow(
tenantId: string,
row: StoredRow,
): Promise<{ pass: boolean; diffs: string[] }> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
if (!tenant) return { pass: false, diffs: ['tenant no encontrado'] };
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const fi = `${row.anio}-${String(row.mes).padStart(2, '0')}-01`;
const lastDay = new Date(row.anio, row.mes, 0).getDate();
const ff = `${row.anio}-${String(row.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
// Ejecutamos secuencial para evitar interferencia entre queries bajo el pool
// limit del tenant (max 3 conexiones). Con Promise.all concurrente, algunas
// queries compartidas de getResumenIva devolvían valores parciales.
const ingresos = await calcularIngresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id);
const egresos = await calcularEgresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id);
const resumenIva = await getResumenIva(pool, fi, ff, tenantId, false, row.contribuyente_id);
const reg = row.regimen_fiscal;
const ingOtf = ingresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const egrOtf = egresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const trasOtf = resumenIva.trasladadoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const acrOtf = resumenIva.acreditablePorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const retOtf = resumenIva.retenidoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const resOtf = trasOtf - acrOtf - retOtf;
const diffs: string[] = [];
const ingStored = Number(row.ingresos_cobrados);
const egrStored = Number(row.egresos_pagados);
const trasStored = Number(row.iva_trasladado_total);
const acrStored = Number(row.iva_acreditable);
const retStored = Number(row.iva_retenido_cobrado);
const resStored = Number(row.iva_resultado);
if (!cmp(ingStored, ingOtf)) diffs.push(`ingresos: tabla=${fmt(ingStored)} vs otf=${fmt(ingOtf)}`);
if (!cmp(egrStored, egrOtf)) diffs.push(`egresos: tabla=${fmt(egrStored)} vs otf=${fmt(egrOtf)}`);
if (!cmp(trasStored, trasOtf)) diffs.push(`ivaTras: tabla=${fmt(trasStored)} vs otf=${fmt(trasOtf)}`);
if (!cmp(acrStored, acrOtf)) diffs.push(`ivaAcr: tabla=${fmt(acrStored)} vs otf=${fmt(acrOtf)}`);
if (!cmp(retStored, retOtf)) diffs.push(`ivaRet: tabla=${fmt(retStored)} vs otf=${fmt(retOtf)}`);
if (!cmp(resStored, resOtf)) diffs.push(`ivaResultado: tabla=${fmt(resStored)} vs otf=${fmt(resOtf)}`);
return { pass: diffs.length === 0, diffs };
}
async function main() {
console.log('=== Validación metricas_mensuales (5 muestras aleatorias por contribuyente) ===\n');
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
let totalMuestras = 0;
let totalPass = 0;
let totalFail = 0;
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows: contribs } = await pool.query<{ entidad_id: string; nombre: string }>(
`SELECT c.entidad_id, eg.nombre
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE EXISTS (
SELECT 1 FROM metricas_mensuales m WHERE m.contribuyente_id = c.entidad_id
)`,
);
if (contribs.length === 0) continue;
console.log(`\n[${t.rfc}] ${contribs.length} contribuyentes con datos`);
for (const c of contribs) {
const { rows: samples } = await pool.query<StoredRow>(
`SELECT contribuyente_id::text, anio, mes, regimen_fiscal,
ingresos_cobrados, egresos_pagados,
iva_trasladado_total, iva_acreditable, iva_retenido_cobrado, iva_resultado,
cfdis_emitidos_count, cfdis_recibidos_count, cfdis_cancelados_count
FROM metricas_mensuales
WHERE contribuyente_id = $1
ORDER BY random()
LIMIT 5`,
[c.entidad_id],
);
console.log(` ${c.nombre} (${samples.length} muestras):`);
for (const s of samples) {
totalMuestras++;
const { pass, diffs } = await validateRow(t.id, s);
const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`;
const reg = s.regimen_fiscal || 'null';
if (pass) {
totalPass++;
console.log(`${mesLabel} reg=${reg} ingresos=$${fmt(Number(s.ingresos_cobrados))}`);
} else {
totalFail++;
console.log(`${mesLabel} reg=${reg} DIFFS:`);
for (const d of diffs) console.log(` - ${d}`);
}
}
}
}
console.log(`\n=== Resumen ===`);
console.log(` Muestras totales: ${totalMuestras}`);
console.log(` PASS: ${totalPass}`);
console.log(` FAIL: ${totalFail}`);
await prisma.$disconnect();
process.exit(totalFail > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

112
apps/api/src/app.ts Normal file
View File

@@ -0,0 +1,112 @@
import express, { type Express } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env, getCorsOrigins } from './config/env.js';
import { errorMiddleware } from './middlewares/error.middleware.js';
import { authRoutes } from './routes/auth.routes.js';
import { dashboardRoutes } from './routes/dashboard.routes.js';
import { cfdiRoutes } from './routes/cfdi.routes.js';
import { impuestosRoutes } from './routes/impuestos.routes.js';
import { exportRoutes } from './routes/export.routes.js';
import { alertasRoutes } from './routes/alertas.routes.js';
import { notificationPreferencesRoutes } from './routes/notification-preferences.routes.js';
import { tareasRoutes } from './routes/tareas.routes.js';
import { papeleriaRoutes } from './routes/papeleria.routes.js';
import { despachoStatsRoutes } from './routes/despacho-stats.routes.js';
import { calendarioRoutes } from './routes/calendario.routes.js';
import { reportesRoutes } from './routes/reportes.routes.js';
import { usuariosRoutes } from './routes/usuarios.routes.js';
import { tenantsRoutes } from './routes/tenants.routes.js';
import fielRoutes from './routes/fiel.routes.js';
import satRoutes from './routes/sat.routes.js';
import { webhookRoutes } from './routes/webhook.routes.js';
import { subscriptionRoutes } from './routes/subscription.routes.js';
import { regimenRoutes } from './routes/regimen.routes.js';
import { bancosRoutes } from './routes/bancos.routes.js';
import { conciliacionRoutes } from './routes/conciliacion.routes.js';
import { facturacionRoutes } from './routes/facturacion.routes.js';
import { catalogosRoutes } from './routes/catalogos.routes.js';
import { documentosRoutes } from './routes/documentos.routes.js';
import { auditLogRoutes } from './routes/audit-log.routes.js';
import { platformStaffRoutes } from './routes/platform-staff.routes.js';
import despachoRoutes from './routes/despacho.routes.js';
import contribuyenteRoutes from './routes/contribuyente.routes.js';
import carteraRoutes from './routes/cartera.routes.js';
import planCatalogoRoutes from './routes/plan-catalogo.routes.js';
import connectorRoutes from './routes/connector.routes.js';
import adminDashboardRoutes from './routes/admin-dashboard.routes.js';
import adminImpersonateRoutes from './routes/admin-impersonate.routes.js';
import adminClientesRoutes from './routes/admin-clientes.routes.js';
import adminAddonsRoutes from './routes/admin-addons.routes.js';
import despachoAuditRoutes from './routes/despacho-audit.routes.js';
import metricasRoutes from './routes/metricas.routes.js';
const app: Express = express();
// Security. Helmet default incluye un CSP restrictivo que puede chocar con el
// frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de
// /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad
// en next.config del web (X-Frame-Options, CSP frame-ancestors, HSTS, nosniff,
// Referrer-Policy) que es quien sirve la UI. El API solo responde JSON y
// archivos binarios (PDFs, XMLs) — no tiene contenido HTML que requiera CSP.
app.use(helmet({
contentSecurityPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' }, // permite /legal/*.pdf embebido
}));
app.use(cors({
origin: getCorsOrigins(),
credentials: true,
}));
// Body parsing - 10MB default, bulk CFDI route has its own higher limit
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/cfdi', cfdiRoutes);
app.use('/api/impuestos', impuestosRoutes);
app.use('/api/export', exportRoutes);
app.use('/api/alertas', alertasRoutes);
app.use('/api/notificaciones', notificationPreferencesRoutes);
app.use('/api/tareas', tareasRoutes);
app.use('/api/papeleria', papeleriaRoutes);
app.use('/api/despachos', despachoStatsRoutes);
app.use('/api/calendario', calendarioRoutes);
app.use('/api/reportes', reportesRoutes);
app.use('/api/usuarios', usuariosRoutes);
app.use('/api/tenants', tenantsRoutes);
app.use('/api/fiel', fielRoutes);
app.use('/api/sat', satRoutes);
app.use('/api/webhooks', webhookRoutes);
app.use('/api/subscriptions', subscriptionRoutes);
app.use('/api/regimenes', regimenRoutes);
app.use('/api/bancos', bancosRoutes);
app.use('/api/conciliacion', conciliacionRoutes);
app.use('/api/facturacion', facturacionRoutes);
app.use('/api/catalogos', catalogosRoutes);
app.use('/api/documentos', documentosRoutes);
app.use('/api/audit-log', auditLogRoutes);
app.use('/api/platform-staff', platformStaffRoutes);
app.use('/api/despachos', despachoRoutes);
app.use('/api/contribuyentes', contribuyenteRoutes);
app.use('/api/carteras', carteraRoutes);
app.use('/api/planes', planCatalogoRoutes);
app.use('/api/connector', connectorRoutes);
app.use('/api/admin/dashboard', adminDashboardRoutes);
app.use('/api/admin/impersonate', adminImpersonateRoutes);
app.use('/api/admin/clientes', adminClientesRoutes);
app.use('/api/admin/addons', adminAddonsRoutes);
app.use('/api/despacho/audit-log', despachoAuditRoutes);
app.use('/api/metricas', metricasRoutes);
// Error handling
app.use(errorMiddleware);
export { app };

View File

@@ -0,0 +1 @@
export { hashPassword, verifyPassword } from '@horux/core';

View File

@@ -0,0 +1,30 @@
import {
generateAccessToken as coreGenerateAccessToken,
generateRefreshToken as coreGenerateRefreshToken,
verifyToken as coreVerifyToken,
decodeToken,
type TokenConfig,
} from '@horux/core';
import type { JWTPayload } from '@horux/shared';
import { env } from '../config/env.js';
const tokenConfig: TokenConfig = {
secret: env.JWT_SECRET,
accessExpiresIn: env.JWT_EXPIRES_IN,
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN,
};
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return coreGenerateAccessToken(payload, tokenConfig);
}
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return coreGenerateRefreshToken(payload, tokenConfig);
}
export function verifyToken(token: string): JWTPayload {
return coreVerifyToken(token, tokenConfig.secret);
}
export { decodeToken };
export type { JWTPayload };

View File

@@ -0,0 +1,234 @@
import { PrismaClient } from '@prisma/client';
import { Pool, type PoolConfig } from 'pg';
import { env } from './env.js';
import { migrate } from './tenant-migrations.js';
// ===========================================
// Prisma Client (central database: horux360)
// ===========================================
declare global {
var prisma: PrismaClient | undefined;
}
export const prisma = globalThis.prisma || new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma;
}
// ===========================================
// TenantConnectionManager (per-tenant DBs)
// ===========================================
interface PoolEntry {
pool: Pool;
lastAccess: Date;
}
function parseDatabaseUrl(url: string) {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: parseInt(parsed.port || '5432'),
user: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
};
}
class TenantConnectionManager {
private pools: Map<string, PoolEntry> = new Map();
private cleanupInterval: NodeJS.Timeout | null = null;
private dbConfig: { host: string; port: number; user: string; password: string };
private migratedPools: Set<string> = new Set();
constructor() {
this.dbConfig = parseDatabaseUrl(env.DATABASE_URL);
this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000);
}
/**
* Get or create a connection pool for a tenant's database.
* Runs lazy migrations on first access (or after pool invalidation).
*/
async getPool(
tenantId: string,
databaseName: string,
connectionOverride?: { host: string; port: number; user: string; password: string },
): Promise<Pool> {
let pool: Pool;
const entry = this.pools.get(tenantId);
if (entry) {
entry.lastAccess = new Date();
pool = entry.pool;
} else {
const poolConfig: PoolConfig = {
host: connectionOverride?.host ?? this.dbConfig.host,
port: connectionOverride?.port ?? this.dbConfig.port,
user: connectionOverride?.user ?? this.dbConfig.user,
password: connectionOverride?.password ?? this.dbConfig.password,
database: databaseName,
max: 3,
idleTimeoutMillis: 300_000,
connectionTimeoutMillis: 10_000,
};
pool = new Pool(poolConfig);
pool.on('error', (err) => {
console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
});
this.pools.set(tenantId, { pool, lastAccess: new Date() });
}
if (!this.migratedPools.has(tenantId)) {
try {
await migrate(pool, databaseName);
} catch (err) {
console.error(`[TenantDB] Migration error for tenant ${tenantId} (${databaseName}):`, err);
}
this.migratedPools.add(tenantId);
}
return pool;
}
/**
* Create a new database for a tenant with all required tables and indexes.
*/
async provisionDatabase(rfc: string, overrideDatabaseName?: string): Promise<string> {
const databaseName = overrideDatabaseName || `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const adminPool = new Pool({
...this.dbConfig,
database: 'postgres',
max: 1,
});
try {
const exists = await adminPool.query(
`SELECT 1 FROM pg_database WHERE datname = $1`,
[databaseName]
);
if (exists.rows.length > 0) {
throw new Error(`Database ${databaseName} already exists`);
}
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
const tenantPool = new Pool({
...this.dbConfig,
database: databaseName,
max: 1,
});
try {
await migrate(tenantPool, databaseName);
} finally {
await tenantPool.end();
}
return databaseName;
} finally {
await adminPool.end();
}
}
/**
* Soft-delete: rename database so it can be recovered.
*/
async deprovisionDatabase(databaseName: string): Promise<void> {
// Close any active pool for this tenant
for (const [tenantId, entry] of this.pools.entries()) {
// We check pool config to match the database
if ((entry.pool as any).options?.database === databaseName) {
await entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
}
const timestamp = Date.now();
const adminPool = new Pool({
...this.dbConfig,
database: 'postgres',
max: 1,
});
try {
await adminPool.query(`
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()
`, [databaseName]);
await adminPool.query(
`ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"`
);
} finally {
await adminPool.end();
}
}
/**
* Invalidate (close and remove) a specific tenant's pool.
*/
invalidatePool(tenantId: string): void {
const entry = this.pools.get(tenantId);
if (entry) {
entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
this.migratedPools.delete(tenantId);
}
/**
* Remove idle pools (not accessed in last 5 minutes).
*/
private cleanupIdlePools(): void {
const now = Date.now();
const maxIdle = 5 * 60 * 1000;
for (const [tenantId, entry] of this.pools.entries()) {
if (now - entry.lastAccess.getTime() > maxIdle) {
entry.pool.end().catch((err) =>
console.error(`[TenantDB] Error closing idle pool for ${tenantId}:`, err.message)
);
this.pools.delete(tenantId);
}
}
}
/**
* Graceful shutdown: close all pools.
*/
async shutdown(): Promise<void> {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
const closePromises = Array.from(this.pools.values()).map((entry) =>
entry.pool.end()
);
await Promise.all(closePromises);
this.pools.clear();
}
/**
* Get stats about active pools.
*/
getStats(): { activePools: number; tenantIds: string[] } {
return {
activePools: this.pools.size,
tenantIds: Array.from(this.pools.keys()),
};
}
}
// Singleton instance
export const tenantDb = new TenantConnectionManager();

View File

@@ -0,0 +1,63 @@
import { z } from 'zod';
import { config } from 'dotenv';
import { resolve } from 'path';
// Load .env file from the api package root
config({ path: resolve(process.cwd(), '.env') });
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().default('4000'),
DATABASE_URL: z.string(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('http://localhost:3000'),
// Frontend URL (for MercadoPago back_url, emails, etc.)
FRONTEND_URL: z.string().default('https://horuxfin.com'),
// FIEL encryption (separate from JWT to allow independent rotation)
FIEL_ENCRYPTION_KEY: z.string().min(32),
FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'),
// MercadoPago
MP_ACCESS_TOKEN: z.string().optional(),
MP_WEBHOOK_SECRET: z.string().optional(),
MP_NOTIFICATION_URL: z.string().optional(),
// SMTP (Gmail Workspace)
SMTP_HOST: z.string().default('smtp.gmail.com'),
SMTP_PORT: z.string().default('587'),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().default('Horux360 <noreply@horuxfin.com>'),
// Admin notification email
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
// Facturapi
FACTURAPI_USER_KEY: z.string().optional(),
// Cloudflare Tunnel (connector BYO-DB)
CLOUDFLARE_API_TOKEN: z.string().optional(),
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
CLOUDFLARE_TUNNEL_DOMAIN: z.string().default('tunnel.horux.mx'),
// KMS for encrypting DB connection strings and connector tokens
CONNECTOR_ENCRYPTION_KEY: z.string().optional(),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
// Parse CORS origins (comma-separated) into array
export function getCorsOrigins(): string[] {
return env.CORS_ORIGIN.split(',').map(origin => origin.trim());
}

View File

@@ -0,0 +1,143 @@
import { Pool } from 'pg';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { prisma } from './database.js';
import { env } from './env.js';
const MIGRATIONS_DIR = join(__dirname, '..', 'migrations', 'tenant');
export interface MigrationFile {
version: number;
name: string;
sql: string;
}
export async function getMigrationFiles(): Promise<MigrationFile[]> {
let files: string[];
try {
files = await readdir(MIGRATIONS_DIR);
} catch (err: any) {
if (err.code === 'ENOENT') {
console.warn(`[Migrations] Directory not found: ${MIGRATIONS_DIR}`);
return [];
}
throw err;
}
const pattern = /^(\d{3})_(.+)\.sql$/;
const migrations: MigrationFile[] = [];
for (const file of files) {
const match = pattern.exec(file);
if (!match) continue;
const version = parseInt(match[1], 10);
const name = file;
const sql = await readFile(join(MIGRATIONS_DIR, file), 'utf8');
migrations.push({ version, name, sql });
}
migrations.sort((a, b) => a.version - b.version);
return migrations;
}
export async function migrate(pool: Pool, label?: string): Promise<number> {
const prefix = label ? `[Migrations] (${label})` : '[Migrations]';
// Ensure schema_migrations table exists
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT NOW()
);
`);
// Get already-applied versions
const { rows } = await pool.query<{ version: number }>(
'SELECT version FROM schema_migrations ORDER BY version'
);
const appliedVersions = new Set(rows.map((r) => r.version));
// Get all migration files
const migrationFiles = await getMigrationFiles();
const pending = migrationFiles.filter((m) => !appliedVersions.has(m.version));
if (pending.length === 0) {
return 0;
}
console.log(`${prefix} Applying ${pending.length} pending migration(s)...`);
for (const migration of pending) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(migration.sql);
await client.query(
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
[migration.version, migration.name]
);
await client.query('COMMIT');
console.log(`${prefix} Applied: ${migration.name}`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
return pending.length;
}
export async function migrateAll(): Promise<{
success: number;
failed: number;
skipped: number;
}> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
let success = 0;
let failed = 0;
let skipped = 0;
for (const tenant of tenants) {
const parsed = new URL(env.DATABASE_URL);
const pool = new Pool({
host: parsed.hostname,
port: parseInt(parsed.port || '5432'),
user: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
database: tenant.databaseName,
max: 1,
});
try {
const applied = await migrate(pool, tenant.rfc);
if (applied > 0) {
success++;
} else {
skipped++;
}
} catch (err: any) {
failed++;
console.error(
`[Migrations] (${tenant.rfc}) Failed: ${err.message}`
);
} finally {
await pool.end();
}
}
console.log(
`[Migrations] Summary — success: ${success}, skipped: ${skipped}, failed: ${failed}`
);
return { success, failed, skipped };
}

View File

@@ -0,0 +1,84 @@
export interface ObligacionFiscal {
id: string;
nombre: string;
fundamento: string;
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'eventual';
fechaLimite: string;
aplica: 'PM' | 'PF' | 'ambos';
regimenes: string[] | null; // null = all regimes
condicion: string | null;
categoria: string;
recomendadaPorDefecto: boolean;
}
export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [
// === FEDERALES MENSUALES (día 17) ===
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
{ id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', recomendadaPorDefecto: false },
// === INFORMATIVAS MENSUALES ===
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
{ id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
{ id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
// === RESICO PM ===
{ id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', recomendadaPorDefecto: true },
// === RESICO PF ===
{ id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', recomendadaPorDefecto: true },
// === ANUALES PM ===
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true },
{ id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
{ id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', recomendadaPorDefecto: false },
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
// === ANUALES PF ===
{ id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true },
// === SEGURIDAD SOCIAL ===
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
{ id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
{ id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
// === ESTATALES ===
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', recomendadaPorDefecto: false },
];
/**
* Returns recommended obligations for a contribuyente based on:
* - PM vs PF (RFC length: 12 = PM, 13 = PF)
* - Specific regímenes
* - Whether they have nómina CFDIs (type N)
*/
export function getRecomendaciones(rfc: string, regimenes: string[], tieneNomina: boolean): ObligacionFiscal[] {
const esPM = rfc.length === 12;
const tipo = esPM ? 'PM' : 'PF';
return OBLIGACIONES_CATALOGO.filter(ob => {
// Filter by PM/PF
if (ob.aplica !== 'ambos' && ob.aplica !== tipo) return false;
// Filter by régimen if specified
if (ob.regimenes && ob.regimenes.length > 0) {
if (!regimenes.some(r => ob.regimenes!.includes(r))) return false;
}
// Always recommend IVA + ISR
if (ob.recomendadaPorDefecto) return true;
// Recommend nómina obligations if they have type N
if (tieneNomina && ob.condicion?.includes('tipo N')) return true;
// Recommend nómina-related social security if has employees
if (tieneNomina && ob.condicion?.includes('empleados')) return true;
return false;
});
}

View File

@@ -0,0 +1,87 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js';
import * as activosFijosService from '../services/activos-fijos.service.js';
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
const listSchema = z.object({
año: z.string().regex(/^\d{4}$/),
mes: z.string().regex(/^\d{1,2}$/),
contribuyenteId: z.string().uuid().optional(),
estado: z.enum(['todos', 'activos', 'baja', 'agotados']).optional(),
});
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = listSchema.parse(req.query);
const data = await activosFijosService.listActivosFijos(
req.tenantPool!,
effectiveTenantId(req),
parseInt(q.año, 10),
parseInt(q.mes, 10),
q.contribuyenteId ?? null,
q.estado,
);
res.json(data);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
const bajaSchema = z.object({
fechaBaja: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
motivo: z.enum(['venta', 'desecho', 'otro']),
comentario: z.string().max(2000).nullable().optional(),
});
export async function darDeBaja(req: Request, res: Response, next: NextFunction) {
try {
const cfdiId = parseInt(String(req.params.cfdiId), 10);
if (isNaN(cfdiId)) return next(new AppError(400, 'cfdiId inválido'));
const data = bajaSchema.parse(req.body);
await activosFijosService.darDeBaja(
req.tenantPool!,
cfdiId,
data.fechaBaja,
data.motivo,
req.user!.userId,
data.comentario ?? null,
);
res.status(201).json({ ok: true });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
const usosExcluidosSchema = z.object({
contribuyenteId: z.string().uuid(),
usos: z.array(z.string().regex(/^I0[1-8]$/)),
});
export async function setUsosExcluidos(req: Request, res: Response, next: NextFunction) {
try {
const { contribuyenteId, usos } = usosExcluidosSchema.parse(req.body);
const saved = await activosFijosService.setUsosExcluidos(req.tenantPool!, contribuyenteId, usos);
res.json({ usosExcluidos: saved });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function revertirBaja(req: Request, res: Response, next: NextFunction) {
try {
const cfdiId = parseInt(String(req.params.cfdiId), 10);
if (isNaN(cfdiId)) return next(new AppError(400, 'cfdiId inválido'));
const ok = await activosFijosService.revertirBaja(req.tenantPool!, cfdiId);
if (!ok) return next(new AppError(404, 'Activo no estaba dado de baja'));
res.status(204).send();
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,86 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { prisma } from '../config/database.js';
import { isPlatformStaff } from '../utils/platform-admin.js';
import { AppError } from '../middlewares/error.middleware.js';
import { auditFromReq } from '../utils/audit.js';
async function requireStaff(req: Request) {
if (!req.user?.userId) throw new AppError(401, 'No autenticado');
const isStaff = await isPlatformStaff(req.user.userId);
if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma');
}
const updateSchema = z.object({
nombre: z.string().min(1).max(200).optional(),
precio: z.number().nonnegative().optional(),
active: z.boolean().optional(),
});
/** Lista todo el catálogo de add-ons (incluye inactivos). */
export async function listCatalogo(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const items = await prisma.planAddonCatalogo.findMany({
orderBy: { codename: 'asc' },
include: {
_count: { select: { subscriptionAddons: { where: { status: { in: ['authorized', 'pending'] } } } } },
},
});
return res.json({
data: items.map(i => ({
id: i.id,
codename: i.codename,
nombre: i.nombre,
verticalProfile: i.verticalProfile,
precio: Number(i.precio),
frecuencia: i.frecuencia,
active: i.active,
delta: i.delta,
createdAt: i.createdAt.toISOString(),
suscripcionesActivas: i._count.subscriptionAddons,
})),
});
} catch (err) { return next(err); }
}
export async function updateCatalogoItem(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const id = String(req.params.id);
const data = updateSchema.parse(req.body);
const before = await prisma.planAddonCatalogo.findUnique({ where: { id } });
if (!before) throw new AppError(404, 'Add-on no encontrado');
const updated = await prisma.planAddonCatalogo.update({
where: { id },
data: {
...(data.nombre !== undefined ? { nombre: data.nombre } : {}),
...(data.precio !== undefined ? { precio: data.precio } : {}),
...(data.active !== undefined ? { active: data.active } : {}),
},
});
auditFromReq(req, 'addon.catalogo_updated', {
entityType: 'PlanAddonCatalogo',
entityId: id,
metadata: {
codename: before.codename,
before: { nombre: before.nombre, precio: Number(before.precio), active: before.active },
after: { nombre: updated.nombre, precio: Number(updated.precio), active: updated.active },
},
});
return res.json({
id: updated.id,
codename: updated.codename,
nombre: updated.nombre,
precio: Number(updated.precio),
frecuencia: updated.frecuencia,
active: updated.active,
});
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}

View File

@@ -0,0 +1,46 @@
import type { Request, Response, NextFunction } from 'express';
import * as svc from '../services/admin-clientes.service.js';
import { isPlatformStaff } from '../utils/platform-admin.js';
import { AppError } from '../middlewares/error.middleware.js';
async function requireStaff(req: Request) {
if (!req.user?.userId) throw new AppError(401, 'No autenticado');
const isStaff = await isPlatformStaff(req.user.userId);
if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma');
}
/**
* Stats de gestión de clientes.
*
* Query params:
* - `from` (YYYY-MM-DD): inicio del rango. Default: primer día del mes en curso.
* - `to` (YYYY-MM-DD): fin del rango. Default: último día del mes en curso.
*/
export async function getStats(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const now = new Date();
const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1);
const defaultTo = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
const fromStr = String(req.query.from || '').trim();
const toStr = String(req.query.to || '').trim();
const from = fromStr ? new Date(fromStr + 'T00:00:00') : defaultFrom;
const to = toStr ? new Date(toStr + 'T23:59:59.999') : defaultTo;
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
return next(new AppError(400, 'Rango de fechas inválido'));
}
const stats = await svc.getClientesStats({ from, to });
return res.json(stats);
} catch (err) { return next(err); }
}
export async function listUsuarios(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const tenantId = String(req.params.tenantId || '');
if (!tenantId) return next(new AppError(400, 'tenantId requerido'));
const usuarios = await svc.getTenantUsuarios(tenantId);
return res.json({ data: usuarios });
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,36 @@
import type { Request, Response, NextFunction } from 'express';
import * as dashService from '../services/admin-dashboard.service.js';
import { isPlatformStaff } from '../utils/platform-admin.js';
import { AppError } from '../middlewares/error.middleware.js';
async function requireStaff(req: Request) {
if (!req.user?.userId) throw new AppError(401, 'No autenticado');
const isStaff = await isPlatformStaff(req.user.userId);
if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma');
}
export async function getMetrics(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const metrics = await dashService.getDashboardMetrics();
return res.json(metrics);
} catch (err) { return next(err); }
}
export async function listDespachos(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const { vertical, status, search } = req.query as Record<string, string>;
const despachos = await dashService.listAllDespachos({ vertical, status, search });
return res.json({ data: despachos });
} catch (err) { return next(err); }
}
export async function getActivity(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const limit = Math.min(Number(req.query.limit) || 20, 100);
const activity = await dashService.getRecentActivity(limit);
return res.json({ data: activity });
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,77 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { prisma } from '../config/database.js';
import { hasPlatformRole } from '../utils/platform-admin.js';
import { auditLog } from '../utils/audit.js';
import { AppError } from '../middlewares/error.middleware.js';
const impersonateSchema = z.object({
despachoId: z.string().uuid('ID de despacho inválido'),
motivo: z.string().min(5, 'Motivo es obligatorio (mínimo 5 caracteres)'),
});
export async function startImpersonation(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user?.userId) return next(new AppError(401, 'No autenticado'));
const canImpersonate = await hasPlatformRole(req.user.userId, 'platform_admin') ||
await hasPlatformRole(req.user.userId, 'platform_ti') ||
await hasPlatformRole(req.user.userId, 'platform_support');
if (!canImpersonate) return next(new AppError(403, 'No tienes permisos para impersonar'));
const { despachoId, motivo } = impersonateSchema.parse(req.body);
const tenant = await prisma.tenant.findUnique({
where: { id: despachoId },
select: { id: true, nombre: true, rfc: true, active: true },
});
if (!tenant) return next(new AppError(404, 'Despacho no encontrado'));
if (!tenant.active) return next(new AppError(403, 'Despacho inactivo'));
await auditLog({
userId: req.user.userId,
tenantId: despachoId,
action: 'admin.impersonate_start',
entityType: 'tenant',
entityId: despachoId,
metadata: {
motivo,
adminEmail: req.user.email,
despachoNombre: tenant.nombre,
despachoRfc: tenant.rfc,
ip: req.ip,
userAgent: req.headers['user-agent'],
},
});
return res.json({
despachoId: tenant.id,
nombre: tenant.nombre,
rfc: tenant.rfc,
message: 'Impersonación iniciada. Usa el header X-View-Tenant para acceder.',
});
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function stopImpersonation(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user?.userId) return next(new AppError(401, 'No autenticado'));
const despachoId = req.body.despachoId as string | undefined;
await auditLog({
userId: req.user.userId,
tenantId: despachoId || undefined,
action: 'admin.impersonate_end',
metadata: {
adminEmail: req.user.email,
ip: req.ip,
},
});
return res.json({ message: 'Impersonación finalizada' });
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,506 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as alertasService from '../services/alertas.service.js';
import { generarAlertasAutomaticas, SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT } from '../services/alertas-auto.service.js';
import { sincronizarAlertasManuales, getAlertasManualesPendientes, resolverAlerta } from '../services/alertas-manuales.service.js';
import { getRegimenesActivosClavesEfectivos } from '../services/regimen.service.js';
import { prisma } from '../config/database.js';
import { AppError } from '../middlewares/error.middleware.js';
const createAlertaSchema = z.object({
tipo: z.enum(['vencimiento', 'discrepancia', 'iva_favor', 'declaracion', 'limite_cfdi', 'custom']),
titulo: z.string().min(1).max(200),
mensaje: z.string().min(1).max(2000),
prioridad: z.enum(['alta', 'media', 'baja']),
fechaVencimiento: z.string().optional(),
});
const updateAlertaSchema = z.object({
leida: z.boolean().optional(),
resuelta: z.boolean().optional(),
});
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
try {
const { leida, resuelta, prioridad } = req.query;
const alertas = await alertasService.getAlertas(req.tenantPool!, {
leida: leida === 'true' ? true : leida === 'false' ? false : undefined,
resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined,
prioridad: prioridad as string,
});
res.json(alertas);
} catch (error) {
next(error);
}
}
export async function getAlerta(req: Request, res: Response, next: NextFunction) {
try {
const alerta = await alertasService.getAlertaById(req.tenantPool!, parseInt(String(req.params.id)));
if (!alerta) {
return res.status(404).json({ message: 'Alerta no encontrada' });
}
res.json(alerta);
} catch (error) {
next(error);
}
}
export async function createAlerta(req: Request, res: Response, next: NextFunction) {
try {
const data = createAlertaSchema.parse(req.body);
const alerta = await alertasService.createAlerta(req.tenantPool!, data);
res.status(201).json(alerta);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
try {
const data = updateAlertaSchema.parse(req.body);
const alerta = await alertasService.updateAlerta(req.tenantPool!, parseInt(String(req.params.id)), data);
res.json(alerta);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
try {
await alertasService.deleteAlerta(req.tenantPool!, parseInt(String(req.params.id)));
res.status(204).send();
} catch (error) {
next(error);
}
}
export async function getStats(req: Request, res: Response, next: NextFunction) {
try {
const stats = await alertasService.getStats(req.tenantPool!);
res.json(stats);
} catch (error) {
next(error);
}
}
export async function markAllAsRead(req: Request, res: Response, next: NextFunction) {
try {
await alertasService.markAllAsRead(req.tenantPool!);
res.json({ success: true });
} catch (error) {
next(error);
}
}
export async function getManualesPendientes(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
// Sincronizar primero (crear alertas para eventos vencidos nuevos)
await sincronizarAlertasManuales(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
// Devolver pendientes (filtered by contribuyente or user role)
const alertas = await getAlertasManualesPendientes(
req.tenantPool!,
contribuyenteId || null,
req.user!.userId,
req.user!.role,
);
res.json(alertas);
} catch (error) {
next(error);
}
}
export async function resolverAlertaManual(req: Request, res: Response, next: NextFunction) {
try {
await resolverAlerta(req.tenantPool!, String(req.params.id));
res.json({ success: true });
} catch (error) {
next(error);
}
}
export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
res.json(alertas);
} catch (error) {
next(error);
}
}
// Drill-down: Clientes en lista negra
export async function getListaNegraClientes(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const listaRfcs = await prisma.listaNegra.findMany({
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
select: { rfc: true, nombre: true, situacion: true },
});
const rfcMap = new Map(listaRfcs.map(l => [l.rfc, l]));
const { rows } = await req.tenantPool!.query(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
COUNT(*)::int as cantidad, SUM(total_mxn) as total
FROM cfdis
WHERE type = 'EMITIDO' AND status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I'
${cf}
GROUP BY rfc_receptor, nombre_receptor
ORDER BY total DESC
`);
const result = rows
.filter((r: any) => rfcMap.has(r.rfc))
.map((r: any) => ({
rfc: r.rfc,
nombre: r.nombre,
cantidad: r.cantidad,
total: Number(r.total),
situacionSat: rfcMap.get(r.rfc)!.situacion,
}));
res.json(result);
} catch (error) {
next(error);
}
}
// Drill-down: Proveedores en lista negra
export async function getListaNegraProveedores(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const listaRfcs = await prisma.listaNegra.findMany({
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
select: { rfc: true, nombre: true, situacion: true },
});
const rfcMap = new Map(listaRfcs.map(l => [l.rfc, l]));
const { rows } = await req.tenantPool!.query(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
COUNT(*)::int as cantidad, SUM(total_mxn) as total
FROM cfdis
WHERE type = 'RECIBIDO' AND status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I'
${cf}
GROUP BY rfc_emisor, nombre_emisor
ORDER BY total DESC
`);
const result = rows
.filter((r: any) => rfcMap.has(r.rfc))
.map((r: any) => ({
rfc: r.rfc,
nombre: r.nombre,
cantidad: r.cantidad,
total: Number(r.total),
situacionSat: rfcMap.get(r.rfc)!.situacion,
}));
res.json(result);
} catch (error) {
next(error);
}
}
// Drill-down: Concentración de clientes
export async function getConcentracionClientes(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const { rows } = await req.tenantPool!.query(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
COUNT(*)::int as cantidad,
SUM(total_mxn) as total
FROM cfdis
WHERE type = 'EMITIDO' AND tipo_comprobante = 'I'
AND status NOT IN ('Cancelado', '0') AND total_mxn > 0
${cf}
GROUP BY rfc_receptor, nombre_receptor
ORDER BY total DESC
`);
const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0);
const result = rows.map((r: any) => ({
rfc: r.rfc,
nombre: r.nombre,
cantidad: r.cantidad,
total: Number(r.total),
participacion: totalGeneral > 0 ? Math.round((Number(r.total) / totalGeneral) * 10000) / 100 : 0,
}));
res.json(result);
} catch (error) {
next(error);
}
}
// Drill-down: Concentración de proveedores
export async function getConcentracionProveedores(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const { rows } = await req.tenantPool!.query(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
COUNT(*)::int as cantidad,
SUM(total_mxn) as total
FROM cfdis
WHERE type = 'RECIBIDO' AND tipo_comprobante = 'I'
AND status NOT IN ('Cancelado', '0') AND total_mxn > 0
${cf}
GROUP BY rfc_emisor, nombre_emisor
ORDER BY total DESC
`);
const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0);
const result = rows.map((r: any) => ({
rfc: r.rfc,
nombre: r.nombre,
cantidad: r.cantidad,
total: Number(r.total),
participacion: totalGeneral > 0 ? Math.round((Number(r.total) / totalGeneral) * 10000) / 100 : 0,
}));
res.json(result);
} catch (error) {
next(error);
}
}
// Drill-down: CFDIs con discrepancia de régimen
export async function getDiscrepanciaRegimen(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const activos = await getRegimenesActivosClavesEfectivos(req.user!.tenantId, req.tenantPool!, contribuyenteId);
if (activos.length === 0) return res.json([]);
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const { rows } = await req.tenantPool!.query(`
SELECT id, uuid, type, fecha_emision as "fechaEmision",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
total_mxn as "totalMxn", regimen_fiscal_receptor as "regimenReceptor"
FROM cfdis
WHERE type = 'RECIBIDO'
AND status = 'Vigente'
AND fecha_cancelacion IS NULL
AND regimen_fiscal_receptor IS NOT NULL
AND regimen_fiscal_receptor != ALL($1)
AND id NOT IN (SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'discrepancia-regimen')
${cf}
ORDER BY fecha_emision DESC
`, [activos]);
res.json(rows);
} catch (error) {
next(error);
}
}
// Drill-down: CFDIs cancelados
export async function getCancelados(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const hace5 = new Date();
hace5.setFullYear(hace5.getFullYear() - 5);
const { rows } = await req.tenantPool!.query(`
SELECT id, uuid, type, fecha_emision as "fechaEmision",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion"
FROM cfdis
WHERE status IN ('Cancelado', '0')
AND fecha_emision >= $1::date
${cf}
ORDER BY fecha_emision DESC
`, [hace5.toISOString().split('T')[0]]);
res.json(rows);
} catch (error) {
next(error);
}
}
// Drill-down: Facturas de periodos anteriores canceladas este mes
export async function getCancelacionesPeriodoAnterior(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const ahora = new Date();
const inicioMes = `${ahora.getFullYear()}-${String(ahora.getMonth() + 1).padStart(2, '0')}-01`;
const { rows } = await req.tenantPool!.query(`
SELECT id, uuid, type, fecha_emision as "fechaEmision",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
total_mxn as "totalMxn", tipo_comprobante as "tipoComprobante",
fecha_cancelacion as "fechaCancelacion"
FROM cfdis
WHERE status IN ('Cancelado', '0')
AND fecha_cancelacion >= $1::date
AND fecha_emision < $1::date
${cf}
ORDER BY fecha_cancelacion DESC
`, [inicioMes]);
res.json(rows);
} catch (error) {
next(error);
}
}
// Drill-down: CFDIs con pago en efectivo
export async function getEfectivo(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const { rows } = await req.tenantPool!.query(`
SELECT id, uuid, type, fecha_emision as "fechaEmision",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
total_mxn as "totalMxn", forma_pago as "formaPago"
FROM cfdis
WHERE status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I'
AND forma_pago = '01'
${cf}
ORDER BY fecha_emision DESC
`);
res.json(rows);
} catch (error) {
next(error);
}
}
// Drill-down: CFDIs tipo E con TipoRelacion sospechoso (debería ser 07)
export async function getTipoRelacionSospechosa(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND c.contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const { rows } = await req.tenantPool!.query(`
SELECT c.id, c.uuid, c.type, c.fecha_emision AS "fechaEmision",
c.rfc_emisor AS "rfcEmisor", c.nombre_emisor AS "nombreEmisor",
c.rfc_receptor AS "rfcReceptor", c.nombre_receptor AS "nombreReceptor",
c.total_mxn AS "totalMxn",
c.tipo_comprobante AS "tipoComprobante",
c.cfdi_tipo_relacion AS "cfdiTipoRelacion",
c.cfdis_relacionados AS "cfdisRelacionados"
FROM cfdis c
WHERE ${SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT}
${cf}
ORDER BY c.fecha_emision DESC
`);
res.json(rows);
} catch (error) {
next(error);
}
}
// ── Descarte de CFDIs de alertas ──
export async function descartarCfdis(req: Request, res: Response, next: NextFunction) {
try {
const { cfdiIds, tipoAlerta } = z.object({
cfdiIds: z.array(z.number().int()),
tipoAlerta: z.string().min(1),
}).parse(req.body);
for (const cfdiId of cfdiIds) {
await req.tenantPool!.query(
`INSERT INTO cfdi_descartados (cfdi_id, tipo_alerta, descartado_por)
VALUES ($1, $2, $3) ON CONFLICT (cfdi_id, tipo_alerta) DO NOTHING`,
[cfdiId, tipoAlerta, req.user!.email],
);
}
res.json({ descartados: cfdiIds.length });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function restaurarDescartados(req: Request, res: Response, next: NextFunction) {
try {
const { cfdiIds, tipoAlerta } = z.object({
cfdiIds: z.array(z.number().int()).optional(),
tipoAlerta: z.string().min(1),
}).parse(req.body);
if (cfdiIds && cfdiIds.length > 0) {
await req.tenantPool!.query(
`DELETE FROM cfdi_descartados WHERE tipo_alerta = $1 AND cfdi_id = ANY($2)`,
[tipoAlerta, cfdiIds],
);
} else {
await req.tenantPool!.query(
`DELETE FROM cfdi_descartados WHERE tipo_alerta = $1`,
[tipoAlerta],
);
}
res.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function getDescartados(req: Request, res: Response, next: NextFunction) {
try {
const tipoAlerta = req.query.tipoAlerta as string;
if (!tipoAlerta) return next(new AppError(400, 'tipoAlerta requerido'));
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND c.contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
// JOIN con cfdis para devolver datos completos (mismo shape que el
// drill-down activo, para que el frontend pueda reutilizar el componente).
const { rows } = await req.tenantPool!.query(`
SELECT c.id, c.uuid, c.type, c.fecha_emision AS "fechaEmision",
c.rfc_emisor AS "rfcEmisor", c.nombre_emisor AS "nombreEmisor",
c.rfc_receptor AS "rfcReceptor", c.nombre_receptor AS "nombreReceptor",
c.total_mxn AS "totalMxn",
c.regimen_fiscal_receptor AS "regimenReceptor",
d.descartado_por AS "descartadoPor",
d.created_at AS "descartadoEn"
FROM cfdi_descartados d
JOIN cfdis c ON c.id = d.cfdi_id
WHERE d.tipo_alerta = $1
${cf}
ORDER BY d.created_at DESC
`, [tipoAlerta]);
res.json({ data: rows });
} catch (error) { next(error); }
}

View File

@@ -0,0 +1,87 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede consultar el audit log' });
}
return isAdmin;
}
/**
* Lista eventos de audit con filtros opcionales. Admin global only.
*
* Query params:
* action — filtra por action prefix (ej: "subscription." matches todas las subs)
* tenantId — filtra a un tenant específico
* userId — filtra a un user específico
* from, to — rango de fechas (ISO)
* page, limit — paginación (default: 1, 50; max limit 200)
*/
export async function listAuditLog(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const action = String(req.query.action || '').trim();
const tenantId = String(req.query.tenantId || '').trim();
const userId = String(req.query.userId || '').trim();
const from = String(req.query.from || '').trim();
const to = String(req.query.to || '').trim();
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
const limit = Math.min(200, Math.max(1, parseInt(String(req.query.limit || '50'), 10) || 50));
const where: any = {};
if (action) where.action = { startsWith: action };
if (tenantId) where.tenantId = tenantId;
if (userId) where.userId = userId;
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = new Date(from);
if (to) where.createdAt.lte = new Date(to);
}
const [total, rows] = await Promise.all([
prisma.auditLog.count({ where }),
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
]);
// Enriquecer con user.email y tenant.nombre para display
const userIds = [...new Set(rows.map(r => r.userId).filter(Boolean))] as string[];
const tenantIds = [...new Set(rows.map(r => r.tenantId).filter(Boolean))] as string[];
const [users, tenants] = await Promise.all([
userIds.length
? prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, email: true, nombre: true } })
: [],
tenantIds.length
? prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, nombre: true, rfc: true } })
: [],
]);
const userMap = new Map(users.map(u => [u.id, u]));
const tenantMap = new Map(tenants.map(t => [t.id, t]));
const data = rows.map(r => ({
...r,
user: r.userId ? userMap.get(r.userId) || null : null,
tenant: r.tenantId ? tenantMap.get(r.tenantId) || null : null,
}));
res.json({
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
});
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,189 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as authService from '../services/auth.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const registerSchema = z.object({
empresa: z.object({
nombre: z.string().min(2, 'Nombre de empresa requerido'),
rfc: z.string().min(12).max(13, 'RFC inválido'),
}),
usuario: z.object({
nombre: z.string().min(2, 'Nombre requerido'),
email: z.string().email('Email inválido'),
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
}),
});
const loginSchema = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(1, 'Contraseña requerida'),
});
export async function register(req: Request, res: Response, next: NextFunction) {
try {
const data = registerSchema.parse(req.body);
const result = await authService.register(data);
res.status(201).json(result);
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
export async function login(req: Request, res: Response, next: NextFunction) {
try {
const data = loginSchema.parse(req.body);
const result = await authService.login(data);
res.json(result);
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
export async function refresh(req: Request, res: Response, next: NextFunction) {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
throw new AppError(400, 'Refresh token requerido');
}
const tokens = await authService.refreshTokens(refreshToken);
res.json(tokens);
} catch (error) {
next(error);
}
}
export async function logout(req: Request, res: Response, next: NextFunction) {
try {
const { refreshToken } = req.body;
if (refreshToken) {
await authService.logout(refreshToken);
}
res.json({ message: 'Sesión cerrada exitosamente' });
} catch (error) {
next(error);
}
}
export async function me(req: Request, res: Response, next: NextFunction) {
try {
res.json({ user: req.user });
} catch (error) {
next(error);
}
}
const passwordResetRequestSchema = z.object({
email: z.string().email('Email inválido'),
});
const passwordResetConfirmSchema = z.object({
token: z.string().min(10, 'Token inválido'),
newPassword: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
});
/**
* Solicita recuperación de contraseña. Responde 200 siempre (anti-enumeration),
* independiente de si el email existe o no.
*/
export async function requestPasswordReset(req: Request, res: Response, next: NextFunction) {
try {
const { email } = passwordResetRequestSchema.parse(req.body);
// Dispara async — no esperamos resultado para preservar timing constante
await authService.requestPasswordReset(email);
res.json({
message: 'Si el email existe en nuestro sistema, recibirás un enlace para restablecer tu contraseña.',
});
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
/**
* Confirma recuperación con token + nueva contraseña.
*/
export async function confirmPasswordReset(req: Request, res: Response, next: NextFunction) {
try {
const { token, newPassword } = passwordResetConfirmSchema.parse(req.body);
await authService.confirmPasswordReset(token, newPassword);
res.json({ message: 'Contraseña actualizada exitosamente. Por favor inicia sesión con tu nueva contraseña.' });
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Contraseña actual requerida'),
newPassword: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
});
/**
* Cambia la contraseña del user autenticado. Requiere contraseña actual.
* Tras cambio: todas las sesiones del user quedan invalidadas (incluyendo esta).
*/
export async function changePassword(req: Request, res: Response, next: NextFunction) {
try {
const { currentPassword, newPassword } = changePasswordSchema.parse(req.body);
await authService.changePassword({
userId: req.user!.userId,
currentPassword,
newPassword,
});
res.json({
message: 'Contraseña actualizada. Por seguridad, todas tus sesiones fueron cerradas. Inicia sesión de nuevo.',
});
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
/**
* "Cerrar todas las sesiones" — invalida todos los tokens del user actual.
*/
export async function logoutAll(req: Request, res: Response, next: NextFunction) {
try {
await authService.logoutAllSessions(req.user!.userId);
res.json({ message: 'Todas tus sesiones fueron cerradas. Inicia sesión de nuevo.' });
} catch (error) {
next(error);
}
}
const switchTenantSchema = z.object({
tenantId: z.string().uuid('tenantId inválido'),
refreshToken: z.string().min(1, 'refreshToken requerido'),
});
/**
* Cambia el tenant activo del user (requiere membership válida). Emite un par
* nuevo de tokens apuntando al tenant destino y revoca el refresh token actual.
*/
export async function switchTenant(req: Request, res: Response, next: NextFunction) {
try {
const { tenantId, refreshToken } = switchTenantSchema.parse(req.body);
const result = await authService.switchTenant({
userId: req.user!.userId,
currentRefreshToken: refreshToken,
targetTenantId: tenantId,
});
res.json(result);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}

View File

@@ -0,0 +1,62 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as bancosService from '../services/bancos.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const createSchema = z.object({
banco: z.string().min(1, 'banco requerido').max(100),
terminacionCuenta: z.string().min(1).max(4, 'terminacionCuenta max 4 digitos'),
});
const updateSchema = z.object({
banco: z.string().min(1).max(100).optional(),
terminacionCuenta: z.string().min(1).max(4).optional(),
});
export async function getBancos(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const bancos = await bancosService.getBancos(req.tenantPool!, contribuyenteId);
res.json(bancos);
} catch (error) { next(error); }
}
export async function createBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' });
const data = createSchema.parse(req.body);
const contribuyenteId = req.body.contribuyenteId as string | undefined;
const result = await bancosService.createBanco(req.tenantPool!, { ...data, contribuyenteId });
res.status(201).json(result);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function updateBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' });
const id = parseInt(String(req.params.id));
const data = updateSchema.parse(req.body);
const result = await bancosService.updateBanco(req.tenantPool!, id, data);
res.json(result);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function deleteBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' });
const id = parseInt(String(req.params.id));
await bancosService.deleteBanco(req.tenantPool!, id);
res.json({ message: 'Banco eliminado' });
} catch (error: any) {
if (error.message?.includes('conciliaciones')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}

View File

@@ -0,0 +1,175 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { generarEventosFiscales, generarEventosDesdeObligaciones } from '../services/calendario-fiscal.service.js';
import * as recordatoriosService from '../services/recordatorios.service.js';
import { getEventosTareasParaCalendario } from '../services/tareas.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import { isDespachoTenant } from '@horux/shared';
import { prisma } from '../config/database.js';
const createRecordatorioSchema = z.object({
titulo: z.string().min(1).max(200),
descripcion: z.string().max(2000).default(''),
fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd
notas: z.string().max(2000).optional(),
privado: z.boolean().optional(),
});
const updateRecordatorioSchema = z.object({
titulo: z.string().min(1).max(200).optional(),
descripcion: z.string().max(2000).optional(),
fechaLimite: z.string().min(8).optional(),
notas: z.string().max(2000).optional(),
privado: z.boolean().optional(),
completado: z.boolean().optional(),
});
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
// Forma compatible con EventoFiscal (sin metadata interna como tareaId/periodoId).
function eventoTareaShape(t: import('../services/tareas.service.js').TareaEventoCalendario) {
return {
titulo: t.titulo,
descripcion: t.descripcion,
tipo: 'tarea' as const,
fechaLimite: t.fechaLimite,
recurrencia: t.recurrencia,
completado: t.completado,
notas: t.notas,
// Metadata adicional para el frontend (links, modales)
tareaId: t.tareaId,
periodoId: t.periodoId,
};
}
export async function getEventosGenerados(req: Request, res: Response, next: NextFunction) {
try {
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const tenantId = effectiveTenantId(req);
let fiscales;
// Determine tenant type by looking up the RFC from the central DB
const tenantRecord = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { rfc: true },
});
const isDespacho = isDespachoTenant(tenantRecord?.rfc);
if (isDespacho) {
const contribuyenteId = (req.query.contribuyenteId as string) || null;
fiscales = await generarEventosDesdeObligaciones(req.tenantPool!, contribuyenteId, año);
} else {
// Horux360: use static catalog as before
fiscales = await generarEventosFiscales(tenantId, año);
}
// Recordatorios custom — always included regardless of tenant type
const custom = await recordatoriosService.getRecordatorios(
req.tenantPool!,
req.user!.userId,
año
);
// Tareas operativas (despacho) — solo si hay contribuyente y rol no es cliente.
// El usuario tipo cliente no debe ver tareas internas del despacho.
let tareas: ReturnType<typeof eventoTareaShape>[] = [];
const contribuyenteIdParam = (req.query.contribuyenteId as string) || null;
if (contribuyenteIdParam && req.user?.role !== 'cliente') {
const tareasRaw = await getEventosTareasParaCalendario(
req.tenantPool!,
contribuyenteIdParam,
año,
);
tareas = tareasRaw.map(eventoTareaShape);
}
// Merge y ordenar por fecha
const todos = [...fiscales, ...custom, ...tareas].sort((a, b) =>
a.fechaLimite.localeCompare(b.fechaLimite)
);
res.json(todos);
} catch (error) {
next(error);
}
}
export async function createRecordatorio(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) {
return res.status(403).json({ message: 'Solo admin y contador pueden crear recordatorios' });
}
const data = createRecordatorioSchema.parse(req.body);
const evento = await recordatoriosService.createRecordatorio(
req.tenantPool!,
req.user!.userId,
{ ...data, tipo: 'custom', recurrencia: 'unica' }
);
res.status(201).json(evento);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function updateRecordatorio(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) {
return res.status(403).json({ message: 'Solo admin y contador pueden editar recordatorios' });
}
const id = parseInt(String(req.params.id));
if (isNaN(id)) {
return res.status(400).json({ message: 'ID inválido' });
}
const data = updateRecordatorioSchema.parse(req.body);
const evento = await recordatoriosService.updateRecordatorio(
req.tenantPool!,
req.user!.userId,
id,
data
);
if (!evento) {
return res.status(404).json({ message: 'Recordatorio no encontrado' });
}
res.json(evento);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function deleteRecordatorio(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) {
return res.status(403).json({ message: 'Solo admin y contador pueden eliminar recordatorios' });
}
const id = parseInt(String(req.params.id));
if (isNaN(id)) {
return res.status(400).json({ message: 'ID inválido' });
}
const deleted = await recordatoriosService.deleteRecordatorio(
req.tenantPool!,
req.user!.userId,
id
);
if (!deleted) {
return res.status(404).json({ message: 'Recordatorio no encontrado' });
}
res.status(204).send();
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,277 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as carteraService from '../services/cartera.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const createSchema = z.object({
nombre: z.string().min(1, 'Nombre requerido'),
descripcion: z.string().optional(),
supervisorUserId: z.string().uuid().optional(), // Owner can assign to a supervisor
});
const createSubcarteraSchema = z.object({
nombre: z.string().min(1, 'Nombre requerido'),
descripcion: z.string().optional(),
auxiliarUserId: z.string().uuid('Auxiliar requerido'),
});
const updateSchema = z.object({
nombre: z.string().min(1).optional(),
descripcion: z.string().optional(),
supervisorUserId: z.string().uuid().optional(),
});
/**
* Permission helpers:
* - Owner: sees all, edits all
* - Supervisor: sees carteras assigned to them (by owner) + carteras they created.
* Can only edit/delete carteras THEY created. Cannot edit owner-created ones.
* Can only add contribuyentes that are already assigned to them.
* - Auxiliar: sees subcarteras where they're assigned. Read-only.
*/
function isOwner(req: Request): boolean {
return req.user!.role === 'owner';
}
function isSupervisor(req: Request): boolean {
return req.user!.role === 'supervisor';
}
/** Check if a supervisor created this cartera (vs owner assigned it to them) */
async function supervisorCreatedCartera(req: Request, cartera: carteraService.CarteraRow): Promise<boolean> {
// A cartera was created by the supervisor if supervisorUserId === the supervisor's userId
// AND the cartera was not created by the owner assigning it.
// We use a heuristic: if the supervisor_user_id matches and createdBy is not tracked,
// we assume the supervisor can edit their own carteras.
// For now: supervisor can edit carteras where they are the supervisor.
// Owner-created carteras also have supervisorUserId set to the supervisor —
// so we need another way to distinguish.
// Solution: we'll add a 'created_by' concept. For now, let supervisor edit all carteras
// assigned to them (both owner-created and self-created).
// The user said: "Las que crea el owner, solo las puede ver el supervisor, pero no las puede editar"
// This requires tracking who created the cartera. Let's use a simple approach:
// check if the owner's userId matches the request user.
return cartera.supervisorUserId === req.user!.userId;
}
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const role = req.user!.role;
const userId = req.user!.userId;
if (isOwner(req)) {
// Owner sees all top-level carteras
const rows = await carteraService.listCarteras(req.tenantPool!);
return res.json({ data: rows });
}
if (isSupervisor(req)) {
// Supervisor sees carteras assigned to them
const rows = await carteraService.listCarteras(req.tenantPool!, userId);
return res.json({ data: rows });
}
// Auxiliar: sees subcarteras where they're assigned
const { rows } = await req.tenantPool!.query(
`SELECT c.id, c.supervisor_user_id AS "supervisorUserId",
c.auxiliar_user_id AS "auxiliarUserId", c.parent_id AS "parentId",
c.nombre, c.descripcion, c.created_at AS "createdAt",
(SELECT count(*) FROM cartera_entidades ce WHERE ce.cartera_id = c.id)::int AS "entidadesCount",
0 AS "subcarterasCount"
FROM carteras c
WHERE c.auxiliar_user_id = $1
ORDER BY c.nombre`,
[userId],
);
return res.json({ data: rows });
} catch (err) { return next(err); }
}
export async function getById(req: Request, res: Response, next: NextFunction) {
try {
const row = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!row) return next(new AppError(404, 'Cartera no encontrada'));
// Auxiliar can only see their own subcarteras
if (req.user!.role === 'auxiliar' && row.auxiliarUserId !== req.user!.userId) {
return next(new AppError(403, 'No autorizado'));
}
return res.json(row);
} catch (err) { return next(err); }
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const data = createSchema.parse(req.body);
const supervisorUserId = data.supervisorUserId || req.user!.userId;
const row = await carteraService.createCartera(req.tenantPool!, {
supervisorUserId,
nombre: data.nombre,
descripcion: data.descripcion,
});
return res.status(201).json(row);
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function update(req: Request, res: Response, next: NextFunction) {
try {
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
// Supervisor cannot edit carteras (owner-assigned are read-only for them)
// Only owner can edit top-level carteras
if (isSupervisor(req)) {
return next(new AppError(403, 'Solo el owner puede editar carteras'));
}
const data = updateSchema.parse(req.body);
const row = await carteraService.updateCartera(req.tenantPool!, String(req.params.id), data);
return res.json(row);
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function remove(req: Request, res: Response, next: NextFunction) {
try {
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
if (isSupervisor(req)) {
return next(new AppError(403, 'Solo el owner puede eliminar carteras'));
}
await carteraService.deleteCartera(req.tenantPool!, String(req.params.id));
return res.json({ message: 'Cartera eliminada' });
} catch (err) { return next(err); }
}
// Subcarteras
export async function listSubcarteras(req: Request, res: Response, next: NextFunction) {
try {
const rows = await carteraService.listSubcarteras(req.tenantPool!, String(req.params.id));
return res.json({ data: rows });
} catch (err) { return next(err); }
}
export async function createSubcartera(req: Request, res: Response, next: NextFunction) {
try {
const parent = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!parent) return next(new AppError(404, 'Cartera padre no encontrada'));
// Supervisor can create subcarteras within their own carteras
if (isSupervisor(req) && parent.supervisorUserId !== req.user!.userId) {
return next(new AppError(403, 'No autorizado'));
}
const data = createSubcarteraSchema.parse(req.body);
const row = await carteraService.createSubcartera(req.tenantPool!, {
parentId: String(req.params.id),
auxiliarUserId: data.auxiliarUserId,
nombre: data.nombre,
descripcion: data.descripcion,
});
return res.status(201).json(row);
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
// Entidades
export async function addEntidad(req: Request, res: Response, next: NextFunction) {
try {
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
if (isSupervisor(req)) {
// For subcarteras: check the parent's supervisor
const supervisorId = cartera.supervisorUserId
|| (cartera.parentId ? (await carteraService.getCarteraById(req.tenantPool!, cartera.parentId))?.supervisorUserId : null);
if (supervisorId !== req.user!.userId) {
return next(new AppError(403, 'No autorizado'));
}
}
const { entidadId } = z.object({ entidadId: z.string().uuid() }).parse(req.body);
await carteraService.addEntidadToCartera(req.tenantPool!, String(req.params.id), entidadId);
return res.json({ message: 'Entidad agregada a cartera' });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function removeEntidad(req: Request, res: Response, next: NextFunction) {
try {
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
if (isSupervisor(req)) {
const supervisorId = cartera.supervisorUserId
|| (cartera.parentId ? (await carteraService.getCarteraById(req.tenantPool!, cartera.parentId))?.supervisorUserId : null);
if (supervisorId !== req.user!.userId) {
return next(new AppError(403, 'No autorizado'));
}
}
await carteraService.removeEntidadFromCartera(req.tenantPool!, String(req.params.id), String(req.params.entidadId));
return res.json({ message: 'Entidad removida de cartera' });
} catch (err) { return next(err); }
}
export async function getEntidades(req: Request, res: Response, next: NextFunction) {
try {
const ids = await carteraService.getCarteraEntidades(req.tenantPool!, String(req.params.id));
return res.json({ data: ids });
} catch (err) { return next(err); }
}
// Auxiliares
export async function getAuxiliares(req: Request, res: Response, next: NextFunction) {
try {
const ids = await carteraService.getCarteraAuxiliares(req.tenantPool!, String(req.params.id));
return res.json({ data: ids });
} catch (err) { return next(err); }
}
export async function addAuxiliar(req: Request, res: Response, next: NextFunction) {
try {
const { auxiliarUserId } = z.object({ auxiliarUserId: z.string().uuid() }).parse(req.body);
await carteraService.addAuxiliarToCartera(req.tenantPool!, String(req.params.id), auxiliarUserId);
return res.json({ message: 'Auxiliar agregado a cartera' });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function removeAuxiliar(req: Request, res: Response, next: NextFunction) {
try {
await carteraService.removeAuxiliarFromCartera(req.tenantPool!, String(req.params.id), String(req.params.auxiliarUserId));
return res.json({ message: 'Auxiliar removido de cartera' });
} catch (err) { return next(err); }
}
// Supervisores available (for dropdown)
export async function getSupervisores(req: Request, res: Response, next: NextFunction) {
try {
const supervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId);
return res.json({ data: supervisores });
} catch (err) { return next(err); }
}
// Auxiliares of a supervisor
export async function getAuxiliaresDelSupervisor(req: Request, res: Response, next: NextFunction) {
try {
const supervisorId = isOwner(req)
? String(req.params.supervisorId || req.user!.userId)
: req.user!.userId;
const rows = await carteraService.getAuxiliaresDelSupervisor(req.tenantPool!, supervisorId);
return res.json({ data: rows });
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,108 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
export async function getFormasPago(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catFormaPago.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getMetodosPago(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catMetodoPago.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getUsosCfdi(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catUsoCfdi.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getMonedas(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catMoneda.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getClavesUnidad(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catClaveUnidad.findMany({ orderBy: { descripcion: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
try {
const q = (req.query.q as string || '').trim();
if (q.length < 2) {
return res.json([]);
}
// Buscar por clave o descripción
// Primero buscar por clave, luego por texto
const data = await prisma.catClaveProdServ.findMany({
where: {
OR: [
{ clave: { startsWith: q } },
{ descripcion: { contains: q, mode: 'insensitive' } },
],
},
take: 20,
orderBy: { clave: 'asc' },
});
// Si no hay resultados, intentar sin acentos
if (data.length === 0) {
const normalized = q.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
if (normalized !== q) {
const fallback = await prisma.catClaveProdServ.findMany({
where: { descripcion: { contains: normalized, mode: 'insensitive' } },
take: 20,
orderBy: { clave: 'asc' },
});
return res.json(fallback);
}
// Buscar con variantes comunes de acentos
const withAccents = normalized
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
.replace(/n/gi, '[nñ]');
// Usar raw SQL con regex para búsqueda flexible
const rows: any[] = await prisma.$queryRawUnsafe(
`SELECT id, clave, descripcion FROM cat_clave_prod_serv WHERE descripcion ~* $1 ORDER BY clave LIMIT 20`,
withAccents
);
return res.json(rows);
}
res.json(data);
} catch (error) { next(error); }
}
export async function getObjetosImp(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catObjetoImp.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getTiposRelacion(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catTipoRelacion.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getExportaciones(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catExportacion.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}

View File

@@ -0,0 +1,446 @@
import type { Request, Response, NextFunction } from 'express';
import * as cfdiService from '../services/cfdi.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js';
import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js';
import { resolveContribuyenteContext } from '../utils/contribuyente-context.js';
import type { CfdiFilters } from '@horux/shared';
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const filters: CfdiFilters = {
tipo: req.query.tipo as any,
tipoComprobante: req.query.tipoComprobante as any,
estado: req.query.estado as any,
fechaInicio: req.query.fechaInicio as string,
fechaFin: req.query.fechaFin as string,
rfc: req.query.rfc as string,
emisor: req.query.emisor as string,
receptor: req.query.receptor as string,
search: req.query.search as string,
contribuyenteId: req.query.contribuyenteId as string,
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 20,
};
const result = await cfdiService.getCfdis(req.tenantPool, filters);
res.json(result);
} catch (error) {
next(error);
}
}
export async function getCfdiById(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const cfdi = await cfdiService.getCfdiById(req.tenantPool, String(req.params.id));
if (!cfdi) {
return next(new AppError(404, 'CFDI no encontrado'));
}
res.json(cfdi);
} catch (error) {
next(error);
}
}
export async function getXml(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const xml = await cfdiService.getXmlById(req.tenantPool, String(req.params.id));
if (!xml) {
return next(new AppError(404, 'XML no encontrado para este CFDI'));
}
res.set('Content-Type', 'application/xml');
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
res.send(xml);
} catch (error) {
next(error);
}
}
export async function getConceptos(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const conceptos = await cfdiService.getConceptos(req.tenantPool, String(req.params.id));
res.json(conceptos);
} catch (error) {
next(error);
}
}
export async function drillDown(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const {
fechaInicio, fechaFin, type, tipoComprobante, metodoPago,
regimenEmisor, regimenReceptor, status, contribuyenteId,
bucket,
} = req.query;
let where = 'WHERE 1=1';
const params: any[] = [];
let pi = 1;
// `bucket` expande la combinación (type, tipo_comprobante, metodo_pago,
// régimen) exactamente igual a la fórmula de KPIs/tarjetas — para que
// el drill-down cuadre línea a línea con el total del header.
//
// Reglas por bucket (alineado con dashboard.service y impuestos.service):
// ingresos: 3 grupos de régimen del emisor con fórmulas distintas.
// Grupo 1 (PF Empresarial 606/612/621/625/626):
// EMIT I PUE + EMIT P + EMIT E PUE (excl. E/07)
// Grupo 2 (Sueldos 605, recibido como N):
// RECIB N PUE con receptor=605
// Grupo 3 (PM y otros): EMIT I PUE+PPD + EMIT E PUE
// gastos: uniforme todos los regímenes del receptor
// RECIB I PUE + RECIB P + RECIB E PUE (excl. E/07)
// causado (IVA): EMIT I PUE + EMIT P + EMIT E PUE (excl. E/07)
// acreditable (IVA): RECIB I PUE + RECIB P + RECIB E PUE (excl. E/07)
//
// Régimenes "ignorados" por el tenant se excluyen en todos los buckets.
// Las NC que restan se muestran como filas con signo (frontend las resta
// del total del header). Si `bucket` se pasa, se ignoran filtros
// type/tipoComprobante/metodoPago de entrada.
const bucketStr = typeof bucket === 'string' ? bucket.toLowerCase() : '';
const bucketApplied = bucketStr === 'ingresos' || bucketStr === 'gastos' ||
bucketStr === 'causado' || bucketStr === 'acreditable';
// Régimenes ignorados por el tenant (configurable en /regimenes). Se
// excluyen del lado correspondiente según el bucket.
const ignorados = req.user?.tenantId
? await getRegimenesIgnoradosClaves(req.user.tenantId)
: [];
// Resolver condiciones esEmisor/esReceptor basadas en RFC del contribuyente.
// Reemplaza `type = 'EMITIDO/RECIBIDO' AND contribuyente_id = X` por un
// filtro por RFC — fuente de verdad cuando dos contribuyentes del tenant
// se facturan entre sí (type/contribuyente_id pueden ser inconsistentes).
const contribIdStr = typeof contribuyenteId === 'string' ? contribuyenteId : undefined;
const cfdiCtx = req.user?.tenantId
? await resolveContribuyenteContext(req.tenantPool, req.user.tenantId, contribIdStr)
: null;
const esEmisor = cfdiCtx?.esEmisor || `type = 'EMITIDO'`;
const esReceptor = cfdiCtx?.esReceptor || `type = 'RECIBIDO'`;
const NO_IGNORADO_EMISOR = ignorados.length > 0
? `AND (regimen_fiscal_emisor IS NULL OR regimen_fiscal_emisor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))`
: '';
const NO_IGNORADO_RECEPTOR = ignorados.length > 0
? `AND (regimen_fiscal_receptor IS NULL OR regimen_fiscal_receptor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))`
: '';
const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(',');
const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(',');
// Conjunto canónico de regímenes que el dashboard considera (excluye 616
// extranjero y otros fuera del catálogo). El drill debe respetarlo para
// cuadrar con los KPIs/tarjetas.
const TODOS_REGS = [...GRUPO_PF_EMPRESARIAL, '605', ...GRUPO_PM_OTROS]
.map(r => `'${r}'`)
.join(',');
const E_NO_ANTICIPO = `COALESCE(cfdi_tipo_relacion, '') <> '07'`;
if (bucketStr === 'ingresos') {
// 3 grupos con fórmulas distintas. Filtro por RFC (esEmisor/esReceptor).
// Grupo 1 usa Método A: todas las I/07 y E/07 se incluyen (sin filtro
// `E_NO_ANTICIPO`) — la suma algebraica se neutraliza correctamente
// cuando anticipo, I/07 y E/07 están en el mismo universo de la query.
where += ` AND (
( -- Grupo 1 PF Empresarial
${esEmisor}
AND regimen_fiscal_emisor IN (${g1})
AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR (tipo_comprobante = 'P')
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE')
)
)
OR ( -- Grupo 2 Sueldos: nómina recibida 605
${esReceptor}
AND tipo_comprobante = 'N'
AND metodo_pago = 'PUE'
AND regimen_fiscal_receptor = '605'
)
OR ( -- Grupo 3 PM y otros
${esEmisor}
AND regimen_fiscal_emisor IN (${g3})
AND (
(tipo_comprobante = 'I' AND metodo_pago IN ('PUE','PPD'))
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE')
)
)
) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`;
} else if (bucketStr === 'gastos') {
// Método A: sin E_NO_ANTICIPO — las E/07 también aparecen en el
// drill (restan del gasto al igual que en el KPI).
where += ` AND (
${esReceptor} AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR (tipo_comprobante = 'P')
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE')
)
AND regimen_fiscal_receptor IN (${TODOS_REGS})
) ${NO_IGNORADO_RECEPTOR}`;
} else if (bucketStr === 'causado') {
where += ` AND (
${esEmisor} AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR (tipo_comprobante = 'P')
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${E_NO_ANTICIPO})
)
AND regimen_fiscal_emisor IN (${TODOS_REGS})
) ${NO_IGNORADO_EMISOR}`;
} else if (bucketStr === 'acreditable') {
where += ` AND (
${esReceptor} AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR (tipo_comprobante = 'P')
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${E_NO_ANTICIPO})
)
AND regimen_fiscal_receptor IN (${TODOS_REGS})
) ${NO_IGNORADO_RECEPTOR}`;
}
// Fecha efectiva: para CFDIs tipo P (complementos de pago) usa fecha_pago_p
// (cuándo el cliente cobró) en vez de fecha_emision (cuándo se emitió el
// complemento). Así el drill-down es coherente con los KPIs — un P emitido
// en mayo que cobró una PPD de noviembre aparece en noviembre, no en mayo.
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
if (fechaInicio) {
where += ` AND ${FECHA_EFECTIVA} >= $${pi++}::date`;
params.push(fechaInicio);
}
if (fechaFin) {
where += ` AND ${FECHA_EFECTIVA} < ($${pi++}::date + interval '1 day')`;
params.push(fechaFin);
}
if (!bucketApplied) {
if (type) {
where += ` AND type = $${pi++}`;
params.push(type);
}
// tipoComprobante acepta valor único ('I') o CSV ('I,P'). Cuando la lista
// incluye P, el filtro metodoPago NO se aplica a los P (que no tienen),
// para que un drill-down "Ingresos del Mes" muestre I PUE + todos los P.
const tiposList = tipoComprobante
? (tipoComprobante as string).split(',').map(t => t.trim()).filter(Boolean)
: [];
const includesP = tiposList.includes('P');
if (tiposList.length === 1) {
where += ` AND tipo_comprobante = $${pi++}`;
params.push(tiposList[0]);
} else if (tiposList.length > 1) {
where += ` AND tipo_comprobante = ANY($${pi++})`;
params.push(tiposList);
}
if (metodoPago) {
const metodos = (metodoPago as string).split(',');
if (includesP) {
// P no tiene metodo_pago: el filtro aplica solo a los no-P
where += ` AND (tipo_comprobante = 'P' OR metodo_pago = ANY($${pi++}))`;
params.push(metodos);
} else {
where += ` AND metodo_pago = ANY($${pi++})`;
params.push(metodos);
}
}
}
if (regimenEmisor) {
where += ` AND regimen_fiscal_emisor = $${pi++}`;
params.push(regimenEmisor);
}
if (regimenReceptor) {
where += ` AND regimen_fiscal_receptor = $${pi++}`;
params.push(regimenReceptor);
}
if (status) {
if (status === 'vigente') {
where += ` AND status NOT IN ('Cancelado', '0')`;
} else {
where += ` AND status IN ('Cancelado', '0')`;
}
}
if (contribuyenteId && !bucketApplied) {
// Solo aplica cuando NO hay bucket (drill crudo, sin semantic de lado).
// Con bucket, esEmisor/esReceptor ya restringen por RFC del contribuyente.
// Sin bucket, filtramos inclusivo: contribuyente_id O RFC en cualquier lado.
if (cfdiCtx) {
where += ` AND ${cfdiCtx.contribFilter.replace(/^AND /, '')}`;
}
}
const { rows } = await req.tenantPool.query(`
SELECT id, uuid, type, tipo_comprobante as "tipoComprobante",
fecha_emision as "fechaEmision", status,
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
subtotal, subtotal_mxn as "subtotalMxn",
total, total_mxn as "totalMxn",
moneda, metodo_pago as "metodoPago",
iva_traslado_mxn as "ivaTrasladoMxn",
iva_retencion_mxn as "ivaRetencionMxn",
isr_retencion_mxn as "isrRetencionMxn",
monto_pago_mxn as "montoPagoMxn",
regimen_fiscal_emisor as "regimenEmisor",
regimen_fiscal_receptor as "regimenReceptor"
FROM cfdis
${where}
ORDER BY fecha_emision DESC
LIMIT 500
`, params);
res.json(rows);
} catch (error) {
next(error);
}
}
export async function getEmisores(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const search = (req.query.search as string) || '';
if (search.length < 2) {
return res.json([]);
}
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const emisores = await cfdiService.getEmisores(req.tenantPool, search, 10, contribuyenteId);
res.json(emisores);
} catch (error) {
next(error);
}
}
export async function getReceptores(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const search = (req.query.search as string) || '';
if (search.length < 2) {
return res.json([]);
}
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const receptores = await cfdiService.getReceptores(req.tenantPool, search, 10, contribuyenteId);
res.json(receptores);
} catch (error) {
next(error);
}
}
export async function getResumen(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const resumen = await cfdiService.getResumenCfdis(req.tenantPool, año, mes, contribuyenteId);
res.json(resumen);
} catch (error) {
next(error);
}
}
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
if (!['owner', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
}
const cfdi = await cfdiService.createCfdi(req.tenantPool, req.body);
res.status(201).json(cfdi);
} catch (error: any) {
if (error.message?.includes('duplicate')) {
return next(new AppError(409, 'Este CFDI ya existe (UUID duplicado)'));
}
next(error);
}
}
export async function createManyCfdis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
if (!['owner', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
}
if (!Array.isArray(req.body.cfdis)) {
return next(new AppError(400, 'Se requiere un array de CFDIs'));
}
const batchInfo = {
batchNumber: req.body.batchNumber || 1,
totalBatches: req.body.totalBatches || 1,
totalFiles: req.body.totalFiles || req.body.cfdis.length
};
console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs`);
const result = await cfdiService.createManyCfdisBatch(req.tenantPool, req.body.cfdis);
res.status(201).json({
message: `Lote ${batchInfo.batchNumber} procesado`,
batchNumber: batchInfo.batchNumber,
totalBatches: batchInfo.totalBatches,
inserted: result.inserted,
duplicates: result.duplicates,
errors: result.errors,
errorMessages: result.errorMessages.slice(0, 5)
});
} catch (error: any) {
console.error('[CFDI Bulk Error]', error.message, error.stack);
next(new AppError(400, error.message || 'Error al procesar CFDIs'));
}
}
export async function deleteCfdi(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
if (!['owner', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
}
await cfdiService.deleteCfdi(req.tenantPool, String(req.params.id));
res.status(204).send();
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,58 @@
import type { Request, Response, NextFunction } from 'express';
import * as conciliacionService from '../services/conciliacion.service.js';
import { prisma } from '../config/database.js';
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
try {
const { tipo, fechaInicio, fechaFin, regimen, estado, contribuyenteId } = req.query;
if (!tipo) return res.status(400).json({ message: 'tipo es requerido (EMITIDO|RECIBIDO)' });
const data = await conciliacionService.getCfdisConConciliacion(req.tenantPool!, {
tipo: tipo as string,
fechaInicio: fechaInicio as string,
fechaFin: fechaFin as string,
regimen: regimen as string,
estado: estado as string,
contribuyenteId: contribuyenteId as string | undefined,
});
res.json(data);
} catch (error) { next(error); }
}
export async function conciliar(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(req.user!.role)) {
return res.status(403).json({ message: 'No autorizado' });
}
const { cfdiIds, fechaDePago, idBanco } = req.body;
if (!cfdiIds?.length || !fechaDePago || !idBanco) {
return res.status(400).json({ message: 'cfdiIds, fechaDePago e idBanco son requeridos' });
}
const tenant = await prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { createdAt: true },
});
const tenantCreatedYear = tenant ? tenant.createdAt.getFullYear() : new Date().getFullYear();
const count = await conciliacionService.conciliar(req.tenantPool!, { cfdiIds, fechaDePago, idBanco }, tenantCreatedYear);
res.json({ message: `${count} CFDIs conciliados`, count });
} catch (error: any) {
if (error.message && !error.message.includes('Internal')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
export async function desconciliar(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(req.user!.role)) {
return res.status(403).json({ message: 'No autorizado' });
}
const id = parseInt(String(req.params.id));
await conciliacionService.desconciliar(req.tenantPool!, id);
res.json({ message: 'CFDI desconciliado' });
} catch (error) { next(error); }
}

View File

@@ -0,0 +1,58 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as connectorService from '../services/connector.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const heartbeatSchema = z.object({
version: z.string(),
uptimeSeconds: z.number().optional().default(0),
postgresPingMs: z.number().optional().default(0),
pgVersion: z.string().optional(),
lastMigration: z.string().optional(),
status: z.string().optional(),
errorMsg: z.string().optional(),
});
// Called by the connector Docker container, NOT by browser users
export async function heartbeat(req: Request, res: Response, next: NextFunction) {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Token requerido' });
}
const token = authHeader.split(' ')[1];
const tenantId = await connectorService.verifyConnectorToken(token);
if (!tenantId) {
return res.status(401).json({ message: 'Token inválido' });
}
const data = heartbeatSchema.parse(req.body);
await connectorService.recordHeartbeat(tenantId, data);
return res.json({ ok: true });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
// Called by authenticated tenant owner to provision or check connector
export async function provision(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = req.viewingTenantId || req.user!.tenantId;
const result = await connectorService.provisionConnector(tenantId);
return res.status(201).json(result);
} catch (err: any) {
if (err.message?.includes('no encontrado')) return next(new AppError(404, err.message));
return next(err);
}
}
export async function status(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = req.viewingTenantId || req.user!.tenantId;
const result = await connectorService.getConnectorStatus(tenantId);
return res.json(result);
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,95 @@
import type { Request, Response, NextFunction } from 'express';
import { AppError } from '../middlewares/error.middleware.js';
import * as fielService from '../services/contribuyente-fiel.service.js';
import * as facturapiService from '../services/contribuyente-facturapi.service.js';
import { getContribuyenteById } from '../services/contribuyente.service.js';
// ========== FIEL ==========
export async function uploadFiel(req: Request, res: Response, next: NextFunction) {
try {
const { cerFile, keyFile, password } = req.body;
if (!cerFile || !keyFile || !password) {
return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
}
const contribuyenteId = String(req.params.id);
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
const result = await fielService.uploadFielContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
if (!result.success) {
console.error('[FIEL Upload] Failed:', result.message);
return res.status(400).json({ message: result.message });
}
return res.json(result);
} catch (err: any) {
console.error('[FIEL Upload] Exception:', err.message || err);
return next(err);
}
}
export async function fielStatus(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const status = await fielService.getFielStatusContribuyente(req.tenantPool!, contribuyenteId);
return res.json(status);
} catch (err) { return next(err); }
}
export async function deleteFiel(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
// Delete from per-contribuyente table (tenant BD)
await req.tenantPool!.query(
'UPDATE fiel_contribuyente SET is_active = false WHERE contribuyente_id = $1',
[contribuyenteId]
);
// Also try to deactivate legacy FIEL if it matches this contribuyente's RFC
const { rows } = await req.tenantPool!.query('SELECT rfc FROM contribuyentes WHERE entidad_id = $1', [contribuyenteId]);
if (rows[0]?.rfc) {
const { prisma } = await import('../config/database.js');
await prisma.fielCredential.updateMany({
where: { rfc: rows[0].rfc },
data: { isActive: false },
}).catch(() => {});
}
return res.json({ message: 'FIEL eliminada' });
} catch (err) { return next(err); }
}
// ========== FACTURAPI ==========
export async function createOrg(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
const result = await facturapiService.createOrgContribuyente(req.tenantPool!, contribuyenteId, contrib.nombre);
return res.status(201).json(result);
} catch (err: any) {
if (err.message?.includes('ya tiene')) return next(new AppError(409, err.message));
return next(err);
}
}
export async function orgStatus(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const status = await facturapiService.getOrgStatusContribuyente(req.tenantPool!, contribuyenteId);
return res.json(status);
} catch (err) { return next(err); }
}
export async function uploadCsd(req: Request, res: Response, next: NextFunction) {
try {
const { cerFile, keyFile, password } = req.body;
if (!cerFile || !keyFile || !password) {
return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
}
const contribuyenteId = String(req.params.id);
const result = await facturapiService.uploadCsdContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
if (!result.success) return res.status(400).json({ message: result.message });
return res.json(result);
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,148 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as contribuyenteService from '../services/contribuyente.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { adjustDespachoOverage } from '../services/payment/addon.service.js';
import { prisma } from '../config/database.js';
/**
* Límite duro de contribuyentes mientras el despacho está en trial gratuito.
* Una vez expira el trial (`trialEndsAt < now`) este límite deja de aplicar y
* el plan vigente toma el control.
*/
const TRIAL_MAX_CONTRIBUYENTES = 5;
/**
* Cuenta contribuyentes activos del tenant actual. Usado para ajustar el
* overage de Business Control / Enterprise tras crear o desactivar un RFC,
* y para enforce el límite del trial.
*/
async function countActiveContribuyentes(pool: import('pg').Pool): Promise<number> {
const { rows: [{ cnt }] } = await pool.query<{ cnt: string }>(
`SELECT COUNT(*)::text AS cnt FROM entidades_gestionadas
WHERE active = true AND tipo = 'CONTRIBUYENTE'`,
);
return Number(cnt) || 0;
}
const createSchema = z.object({
rfc: z.string().regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i, 'RFC inválido'),
razonSocial: z.string().min(2, 'Razón social requerida'),
regimenFiscal: z.string().length(3).optional(),
codigoPostal: z.string().regex(/^\d{5}$/).optional(),
domicilio: z.record(z.unknown()).optional(),
supervisorUserId: z.string().uuid().optional(),
});
const updateSchema = createSchema.partial();
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds);
return res.json({ data: rows });
} catch (err) { return next(err); }
}
export async function getById(req: Request, res: Response, next: NextFunction) {
try {
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id));
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
return res.json(row);
} catch (err) { return next(err); }
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const data = createSchema.parse(req.body);
// Trial gate: durante el periodo de prueba (trialEndsAt > now) el despacho
// no puede gestionar más de TRIAL_MAX_CONTRIBUYENTES RFCs activos. Cuando
// el trial expira, deja de aplicar y el límite del plan vigente toma el control.
const tenant = await prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { trialEndsAt: true },
});
const isTrialActive = tenant?.trialEndsAt ? tenant.trialEndsAt > new Date() : false;
if (isTrialActive) {
const activeCount = await countActiveContribuyentes(req.tenantPool!);
if (activeCount >= TRIAL_MAX_CONTRIBUYENTES) {
return next(new AppError(
403,
`Durante el periodo de prueba puedes gestionar hasta ${TRIAL_MAX_CONTRIBUYENTES} contribuyentes. Contrata un plan para agregar más.`,
));
}
}
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data);
// Ajuste de overage despacho: si el tenant pasa de 100 a 101+ RFCs, crea
// el addon y devuelve paymentUrl para que el frontend redirija al usuario.
// Fail-soft: si falla el addon, el contribuyente queda creado y se loguea.
let overage: Awaited<ReturnType<typeof adjustDespachoOverage>> | null = null;
try {
const activeCount = await countActiveContribuyentes(req.tenantPool!);
overage = await adjustDespachoOverage(req.user!.tenantId, activeCount);
} catch (err: any) {
console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err);
}
return res.status(201).json({ ...row, overage });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
if (err.code === '23505') return next(new AppError(409, 'Ya existe un contribuyente con este RFC'));
return next(err);
}
}
export async function update(req: Request, res: Response, next: NextFunction) {
try {
const data = updateSchema.parse(req.body);
const row = await contribuyenteService.updateContribuyente(req.tenantPool!, String(req.params.id), data);
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
return res.json(row);
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function deactivate(req: Request, res: Response, next: NextFunction) {
try {
const ok = await contribuyenteService.deactivateContribuyente(req.tenantPool!, String(req.params.id));
if (!ok) return next(new AppError(404, 'Contribuyente no encontrado'));
// Ajuste de overage despacho: si el count baja, reduce quantity del
// addon (updatePreapprovalAmount) o cancela el preapproval si pasa al límite.
let overage: Awaited<ReturnType<typeof adjustDespachoOverage>> | null = null;
try {
const activeCount = await countActiveContribuyentes(req.tenantPool!);
overage = await adjustDespachoOverage(req.user!.tenantId, activeCount);
} catch (err: any) {
console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err);
}
return res.json({ message: 'Contribuyente desactivado', overage });
} catch (err) { return next(err); }
}
export async function backfill(req: Request, res: Response, next: NextFunction) {
try {
const total = await contribuyenteService.backfillAllContribuyentes(req.tenantPool!);
return res.json({ message: `${total} CFDIs asignados a contribuyentes`, total });
} catch (err) { return next(err); }
}
export async function addClienteAcceso(req: Request, res: Response, next: NextFunction) {
try {
const { userId } = req.body;
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
const entidadId = String(req.params.id);
await req.tenantPool!.query(
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[userId, entidadId],
);
return res.json({ message: 'Acceso otorgado' });
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,108 @@
import type { Request, Response, NextFunction } from 'express';
import * as dashboardService from '../services/dashboard.service.js';
import { generarAlertasAutomaticas } from '../services/alertas-auto.service.js';
import { getAlertasManualesPendientes } from '../services/alertas-manuales.service.js';
import { AppError } from '../middlewares/error.middleware.js';
function getDefaultRange() {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const lastDay = new Date(y, m, 0).getDate();
return {
fechaInicio: `${y}-${String(m).padStart(2, '0')}-01`,
fechaFin: `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`,
año: y,
mes: m,
};
}
function parseConciliacion(req: Request): boolean {
return req.query.conciliacion === 'true' || req.query.conciliacion === '1';
}
export async function getKpis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const defaults = getDefaultRange();
const fechaInicio = (req.query.fechaInicio as string) || defaults.fechaInicio;
const fechaFin = (req.query.fechaFin as string) || defaults.fechaFin;
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const tenantId = req.viewingTenantId || req.user!.tenantId;
const kpis = await dashboardService.getKpis(req.tenantPool, fechaInicio, fechaFin, tenantId, conciliacion, contribuyenteId);
res.json(kpis);
} catch (error) {
next(error);
}
}
export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const tenantId = req.viewingTenantId || req.user!.tenantId;
const data = await dashboardService.getIngresosEgresos(req.tenantPool, año, tenantId, conciliacion, contribuyenteId);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getRegimenesDelPeriodo(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const defaults = getDefaultRange();
const fechaInicio = (req.query.fechaInicio as string) || defaults.fechaInicio;
const fechaFin = (req.query.fechaFin as string) || defaults.fechaFin;
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const tenantId = req.viewingTenantId || req.user?.tenantId;
const regimenes = await dashboardService.getRegimenesDelPeriodo(req.tenantPool, fechaInicio, fechaFin, conciliacion, contribuyenteId, tenantId);
res.json(regimenes);
} catch (error) {
next(error);
}
}
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const limit = parseInt(req.query.limit as string) || 5;
const tenantId = req.viewingTenantId || req.user!.tenantId;
const contribuyenteId = (req.query.contribuyenteId as string) || null;
// Combinar alertas persistidas (manuales, filtered by role) + automáticas (calculadas)
const [manuales, automaticas] = await Promise.all([
getAlertasManualesPendientes(req.tenantPool, contribuyenteId, req.user!.userId, req.user!.role),
generarAlertasAutomaticas(req.tenantPool, tenantId, contribuyenteId),
]);
// Unir, ordenar por prioridad, y limitar
const prioridadOrden: Record<string, number> = { alta: 1, media: 2, baja: 3 };
const alertas = [...automaticas, ...manuales]
.sort((a, b) => (prioridadOrden[a.prioridad] || 3) - (prioridadOrden[b.prioridad] || 3))
.slice(0, limit);
res.json(alertas);
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,67 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { AppError } from '../middlewares/error.middleware.js';
export async function getDespachoAuditLog(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) return next(new AppError(401, 'No autenticado'));
const tenantId = req.viewingTenantId || req.user.tenantId;
// Only owner or cfo can see audit log of their despacho
if (req.user.role !== 'owner' && req.user.role !== 'cfo') {
return next(new AppError(403, 'Solo el dueño puede ver el registro de accesos'));
}
const from = req.query.from
? new Date(req.query.from as string)
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const to = req.query.to ? new Date(req.query.to as string) : new Date();
const limit = Math.min(Number(req.query.limit) || 50, 200);
const logs = await prisma.auditLog.findMany({
where: {
tenantId,
action: { startsWith: 'admin.' },
createdAt: { gte: from, lte: to },
},
orderBy: { createdAt: 'desc' },
take: limit,
});
// Enrich with admin user info
const userIds = [...new Set(logs.filter(l => l.userId).map(l => l.userId!))];
const users =
userIds.length > 0
? await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, nombre: true, email: true },
})
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const enriched = logs.map(log => ({
id: log.id,
action: log.action,
timestamp: log.createdAt.toISOString(),
admin: log.userId
? {
nombre: userMap.get(log.userId)?.nombre ?? 'Desconocido',
email: userMap.get(log.userId)?.email ?? '',
}
: null,
motivo: (log.metadata as any)?.motivo ?? null,
ip: (log.metadata as any)?.ip ?? null,
details: log.metadata,
}));
return res.json({
data: enriched,
total: enriched.length,
from: from.toISOString(),
to: to.toISOString(),
});
} catch (err) {
return next(err);
}
}

View File

@@ -0,0 +1,67 @@
import type { Request, Response, NextFunction } from 'express';
import { AppError } from '../middlewares/error.middleware.js';
import * as despachoService from '../services/despacho-stats.service.js';
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
const ROLES_OWNER = new Set(['owner', 'cfo']);
const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']);
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
export async function getContribuyentesStats(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_OWNER.has(req.user!.role)) {
throw new AppError(403, 'Solo owner puede ver estas métricas');
}
const tenantId = effectiveTenantId(req);
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined;
const stats = await despachoService.getContribuyentesStats(req.tenantPool!, tenantId, año, mes);
res.json(stats);
} catch (error) {
next(error);
}
}
export async function getMisAsignados(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_ASIGNADOS.has(req.user!.role)) {
throw new AppError(403, 'No tienes contribuyentes asignados');
}
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined;
const data = await despachoService.getMisAsignados(
req.tenantPool!,
req.user!.userId,
req.user!.role,
año,
mes,
);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getEquipoStats(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_SUPERVISORY.has(req.user!.role)) {
throw new AppError(403, 'Solo owner y supervisor pueden ver al equipo');
}
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined;
const data = await despachoService.getEquipoStats(
req.tenantPool!,
req.user!.userId,
req.user!.role,
effectiveTenantId(req),
año,
mes,
);
res.json(data);
} catch (error) {
next(error);
}
}

Some files were not shown because too many files have changed in this diff Show More