Update: nueva version Horux Despachos
This commit is contained in:
7
apps/api/.env.example
Normal file
7
apps/api/.env.example
Normal 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
65
apps/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
121
apps/api/prisma/catalogos-sat-data.ts
Normal file
121
apps/api/prisma/catalogos-sat-data.ts
Normal 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' },
|
||||
];
|
||||
185
apps/api/prisma/eventos-fiscales-data.ts
Normal file
185
apps/api/prisma/eventos-fiscales-data.ts
Normal 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
103
apps/api/prisma/isr-data.ts
Normal 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 },
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "sat_sync_jobs" ADD COLUMN "contribuyente_id" TEXT;
|
||||
@@ -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';
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "Plan" ADD VALUE 'mi_empresa';
|
||||
@@ -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());
|
||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
3
apps/api/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
744
apps/api/prisma/schema.prisma
Normal file
744
apps/api/prisma/schema.prisma
Normal 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
559
apps/api/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
158
apps/api/scripts/backfill-cfdi-contribuyente.ts
Normal file
158
apps/api/scripts/backfill-cfdi-contribuyente.ts
Normal 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);
|
||||
});
|
||||
209
apps/api/scripts/backfill-cfdis-relaciones.ts
Normal file
209
apps/api/scripts/backfill-cfdis-relaciones.ts
Normal 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);
|
||||
});
|
||||
126
apps/api/scripts/backfill-facturapi-cfdis.ts
Normal file
126
apps/api/scripts/backfill-facturapi-cfdis.ts
Normal 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); });
|
||||
174
apps/api/scripts/backfill-fechas-tz.ts
Normal file
174
apps/api/scripts/backfill-fechas-tz.ts
Normal 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); });
|
||||
101
apps/api/scripts/backfill-metricas.ts
Normal file
101
apps/api/scripts/backfill-metricas.ts
Normal 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);
|
||||
});
|
||||
163
apps/api/scripts/backfill-saldo-pendiente.ts
Normal file
163
apps/api/scripts/backfill-saldo-pendiente.ts
Normal 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);
|
||||
});
|
||||
135
apps/api/scripts/bootstrap-horux360-admin.ts
Normal file
135
apps/api/scripts/bootstrap-horux360-admin.ts
Normal 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());
|
||||
75
apps/api/scripts/breakdown-gastos.ts
Normal file
75
apps/api/scripts/breakdown-gastos.ts
Normal 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); });
|
||||
67
apps/api/scripts/breakdown-ingresos.ts
Normal file
67
apps/api/scripts/breakdown-ingresos.ts
Normal 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); });
|
||||
24
apps/api/scripts/check-cache-contrib.ts
Normal file
24
apps/api/scripts/check-cache-contrib.ts
Normal 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); });
|
||||
26
apps/api/scripts/check-cache.ts
Normal file
26
apps/api/scripts/check-cache.ts
Normal 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); });
|
||||
85
apps/api/scripts/check-carlos-emision.ts
Normal file
85
apps/api/scripts/check-carlos-emision.ts
Normal 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);
|
||||
});
|
||||
72
apps/api/scripts/check-carlos-lco.ts
Normal file
72
apps/api/scripts/check-carlos-lco.ts
Normal 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); });
|
||||
112
apps/api/scripts/check-ieps-inflation.ts
Normal file
112
apps/api/scripts/check-ieps-inflation.ts
Normal 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); });
|
||||
76
apps/api/scripts/check-recent-facturapi.ts
Normal file
76
apps/api/scripts/check-recent-facturapi.ts
Normal 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);
|
||||
});
|
||||
36
apps/api/scripts/check-rfc-emisor.ts
Normal file
36
apps/api/scripts/check-rfc-emisor.ts
Normal 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); });
|
||||
63
apps/api/scripts/check-saldo.ts
Normal file
63
apps/api/scripts/check-saldo.ts
Normal 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); });
|
||||
37
apps/api/scripts/compare-iva-full.ts
Normal file
37
apps/api/scripts/compare-iva-full.ts
Normal 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); });
|
||||
36
apps/api/scripts/compare-iva-gastos.ts
Normal file
36
apps/api/scripts/compare-iva-gastos.ts
Normal 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); });
|
||||
22
apps/api/scripts/count-07-types.ts
Normal file
22
apps/api/scripts/count-07-types.ts
Normal 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); });
|
||||
27
apps/api/scripts/count-husberto-07.ts
Normal file
27
apps/api/scripts/count-husberto-07.ts
Normal 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); });
|
||||
26
apps/api/scripts/create-carlos.ts
Normal file
26
apps/api/scripts/create-carlos.ts
Normal 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); });
|
||||
88
apps/api/scripts/debug-i07.ts
Normal file
88
apps/api/scripts/debug-i07.ts
Normal 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); });
|
||||
104
apps/api/scripts/debug-ingresos-horux-may-wider.ts
Normal file
104
apps/api/scripts/debug-ingresos-horux-may-wider.ts
Normal 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); });
|
||||
111
apps/api/scripts/debug-ingresos-horux-may.ts
Normal file
111
apps/api/scripts/debug-ingresos-horux-may.ts
Normal 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); });
|
||||
82
apps/api/scripts/decrypt-fiel.ts
Normal file
82
apps/api/scripts/decrypt-fiel.ts
Normal 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);
|
||||
});
|
||||
101
apps/api/scripts/deep-egresos.ts
Normal file
101
apps/api/scripts/deep-egresos.ts
Normal 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); });
|
||||
55
apps/api/scripts/detail-ingresos.ts
Normal file
55
apps/api/scripts/detail-ingresos.ts
Normal 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); });
|
||||
68
apps/api/scripts/detail-iva-mes.ts
Normal file
68
apps/api/scripts/detail-iva-mes.ts
Normal 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); });
|
||||
88
apps/api/scripts/drill-ingresos.ts
Normal file
88
apps/api/scripts/drill-ingresos.ts
Normal 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); });
|
||||
79
apps/api/scripts/extract-terminos.mjs
Normal file
79
apps/api/scripts/extract-terminos.mjs
Normal 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);
|
||||
});
|
||||
28
apps/api/scripts/find-contribuyente.ts
Normal file
28
apps/api/scripts/find-contribuyente.ts
Normal 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); });
|
||||
75
apps/api/scripts/find-i07-ppd-cases.ts
Normal file
75
apps/api/scripts/find-i07-ppd-cases.ts
Normal 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); });
|
||||
12
apps/api/scripts/find-uuid.ts
Normal file
12
apps/api/scripts/find-uuid.ts
Normal 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); });
|
||||
104
apps/api/scripts/import-lista-negra.ts
Normal file
104
apps/api/scripts/import-lista-negra.ts
Normal 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());
|
||||
26
apps/api/scripts/inspect-cfdi-full.ts
Normal file
26
apps/api/scripts/inspect-cfdi-full.ts
Normal 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); });
|
||||
90
apps/api/scripts/inspect-cfdi.ts
Normal file
90
apps/api/scripts/inspect-cfdi.ts
Normal 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);
|
||||
});
|
||||
66
apps/api/scripts/inspect-facturapi-invoice.ts
Normal file
66
apps/api/scripts/inspect-facturapi-invoice.ts
Normal 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); });
|
||||
41
apps/api/scripts/inspect-latest-facturapi.ts
Normal file
41
apps/api/scripts/inspect-latest-facturapi.ts
Normal 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); });
|
||||
52
apps/api/scripts/inspect-pair.ts
Normal file
52
apps/api/scripts/inspect-pair.ts
Normal 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); });
|
||||
48
apps/api/scripts/inspect-rfc.ts
Normal file
48
apps/api/scripts/inspect-rfc.ts
Normal 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); });
|
||||
159
apps/api/scripts/invalidate-metricas-all.ts
Normal file
159
apps/api/scripts/invalidate-metricas-all.ts
Normal 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);
|
||||
});
|
||||
26
apps/api/scripts/list-contribuyentes.ts
Normal file
26
apps/api/scripts/list-contribuyentes.ts
Normal 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); });
|
||||
33
apps/api/scripts/migrate-tenants.ts
Normal file
33
apps/api/scripts/migrate-tenants.ts
Normal 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);
|
||||
});
|
||||
27
apps/api/scripts/otf-ingresos.ts
Normal file
27
apps/api/scripts/otf-ingresos.ts
Normal 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); });
|
||||
164
apps/api/scripts/preview-emails.mjs
Normal file
164
apps/api/scripts/preview-emails.mjs
Normal 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);
|
||||
});
|
||||
32
apps/api/scripts/process-metricas-now.ts
Normal file
32
apps/api/scripts/process-metricas-now.ts
Normal 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);
|
||||
});
|
||||
71
apps/api/scripts/setup-despachos-db.ts
Normal file
71
apps/api/scripts/setup-despachos-db.ts
Normal 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());
|
||||
47
apps/api/scripts/sweep-stale-sat-jobs.ts
Normal file
47
apps/api/scripts/sweep-stale-sat-jobs.ts
Normal 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);
|
||||
});
|
||||
96
apps/api/scripts/test-emails.ts
Normal file
96
apps/api/scripts/test-emails.ts
Normal 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);
|
||||
});
|
||||
97
apps/api/scripts/validate-dashboard-impuestos.ts
Normal file
97
apps/api/scripts/validate-dashboard-impuestos.ts
Normal 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);
|
||||
});
|
||||
115
apps/api/scripts/validate-gastos.ts
Normal file
115
apps/api/scripts/validate-gastos.ts
Normal 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); });
|
||||
39
apps/api/scripts/validate-ingresos.ts
Normal file
39
apps/api/scripts/validate-ingresos.ts
Normal 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); });
|
||||
160
apps/api/scripts/validate-metricas.ts
Normal file
160
apps/api/scripts/validate-metricas.ts
Normal 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
112
apps/api/src/app.ts
Normal 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 };
|
||||
1
apps/api/src/auth/passwords.ts
Normal file
1
apps/api/src/auth/passwords.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { hashPassword, verifyPassword } from '@horux/core';
|
||||
30
apps/api/src/auth/tokens.ts
Normal file
30
apps/api/src/auth/tokens.ts
Normal 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 };
|
||||
234
apps/api/src/config/database.ts
Normal file
234
apps/api/src/config/database.ts
Normal 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();
|
||||
63
apps/api/src/config/env.ts
Normal file
63
apps/api/src/config/env.ts
Normal 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());
|
||||
}
|
||||
143
apps/api/src/config/tenant-migrations.ts
Normal file
143
apps/api/src/config/tenant-migrations.ts
Normal 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 };
|
||||
}
|
||||
84
apps/api/src/constants/obligaciones-fiscales.ts
Normal file
84
apps/api/src/constants/obligaciones-fiscales.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
87
apps/api/src/controllers/activos-fijos.controller.ts
Normal file
87
apps/api/src/controllers/activos-fijos.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
86
apps/api/src/controllers/admin-addons.controller.ts
Normal file
86
apps/api/src/controllers/admin-addons.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
apps/api/src/controllers/admin-clientes.controller.ts
Normal file
46
apps/api/src/controllers/admin-clientes.controller.ts
Normal 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); }
|
||||
}
|
||||
36
apps/api/src/controllers/admin-dashboard.controller.ts
Normal file
36
apps/api/src/controllers/admin-dashboard.controller.ts
Normal 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); }
|
||||
}
|
||||
77
apps/api/src/controllers/admin-impersonate.controller.ts
Normal file
77
apps/api/src/controllers/admin-impersonate.controller.ts
Normal 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); }
|
||||
}
|
||||
506
apps/api/src/controllers/alertas.controller.ts
Normal file
506
apps/api/src/controllers/alertas.controller.ts
Normal 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); }
|
||||
}
|
||||
87
apps/api/src/controllers/audit-log.controller.ts
Normal file
87
apps/api/src/controllers/audit-log.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
189
apps/api/src/controllers/auth.controller.ts
Normal file
189
apps/api/src/controllers/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
62
apps/api/src/controllers/bancos.controller.ts
Normal file
62
apps/api/src/controllers/bancos.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
175
apps/api/src/controllers/calendario.controller.ts
Normal file
175
apps/api/src/controllers/calendario.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
277
apps/api/src/controllers/cartera.controller.ts
Normal file
277
apps/api/src/controllers/cartera.controller.ts
Normal 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); }
|
||||
}
|
||||
108
apps/api/src/controllers/catalogos.controller.ts
Normal file
108
apps/api/src/controllers/catalogos.controller.ts
Normal 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); }
|
||||
}
|
||||
446
apps/api/src/controllers/cfdi.controller.ts
Normal file
446
apps/api/src/controllers/cfdi.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
58
apps/api/src/controllers/conciliacion.controller.ts
Normal file
58
apps/api/src/controllers/conciliacion.controller.ts
Normal 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); }
|
||||
}
|
||||
58
apps/api/src/controllers/connector.controller.ts
Normal file
58
apps/api/src/controllers/connector.controller.ts
Normal 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); }
|
||||
}
|
||||
95
apps/api/src/controllers/contribuyente-config.controller.ts
Normal file
95
apps/api/src/controllers/contribuyente-config.controller.ts
Normal 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); }
|
||||
}
|
||||
148
apps/api/src/controllers/contribuyente.controller.ts
Normal file
148
apps/api/src/controllers/contribuyente.controller.ts
Normal 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); }
|
||||
}
|
||||
108
apps/api/src/controllers/dashboard.controller.ts
Normal file
108
apps/api/src/controllers/dashboard.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
67
apps/api/src/controllers/despacho-audit.controller.ts
Normal file
67
apps/api/src/controllers/despacho-audit.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
67
apps/api/src/controllers/despacho-stats.controller.ts
Normal file
67
apps/api/src/controllers/despacho-stats.controller.ts
Normal 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
Reference in New Issue
Block a user