Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

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

@@ -0,0 +1,82 @@
# =============================================================================
# Horux 360 — API .env template
# =============================================================================
# Copiá este archivo a `.env` en producción y rellená cada variable.
# Las marcadas REQUIRED son obligatorias — la app no arranca sin ellas (Zod).
# Las opcionales pueden quedar comentadas; sus features se desactivan en runtime.
#
# Validación: apps/api/src/config/env.ts
# =============================================================================
# ----- Runtime ---------------------------------------------------------------
NODE_ENV=production # development | production | test
PORT=4000 # default 4000
# ----- BD central (Prisma) — REQUIRED ----------------------------------------
DATABASE_URL=postgresql://user:password@localhost:5432/horux360
# ----- JWT — REQUIRED --------------------------------------------------------
# Generar con: `openssl rand -hex 64`
JWT_SECRET= # min 32 chars
JWT_EXPIRES_IN=15m # access token TTL
JWT_REFRESH_EXPIRES_IN=7d # refresh token TTL
# ----- CORS / URLs -----------------------------------------------------------
CORS_ORIGIN=https://horuxfin.com # comma-separated si son varios
FRONTEND_URL=https://horuxfin.com # usado por MP back_url, emails, etc.
# ----- FIEL (cifrado de credenciales SAT) — REQUIRED -------------------------
# Generar con: `openssl rand -hex 64` (DISTINTA al JWT_SECRET — rotación independiente)
FIEL_ENCRYPTION_KEY= # min 32 chars
FIEL_STORAGE_PATH=/var/horux/fiel # path donde se guardan archivos FIEL temporales
# ----- MercadoPago (suscripciones self-serve) --------------------------------
MP_ACCESS_TOKEN= # producción: APP_USR-...
MP_ACCESS_TOKEN_SANDBOX= # opcional: TEST-... para dev local sin cobro
MP_USE_SANDBOX=false # true → usa MP_ACCESS_TOKEN_SANDBOX
MP_WEBHOOK_SECRET= # firma HMAC del webhook MP (Settings → Notifs)
MP_NOTIFICATION_URL=https://api.horuxfin.com/api/webhooks/mercadopago
# Solo dev/staging — override del payer_email cuando el owner = collector. Vacío en prod.
MP_TEST_PAYER_EMAIL=
# ----- SMTP (Nodemailer) — opcional pero recomendado -------------------------
SMTP_HOST=smtp.gmail.com # default
SMTP_PORT=587 # default
SMTP_USER= # cuenta Gmail Workspace
SMTP_PASS= # app password (NO la password de la cuenta)
SMTP_FROM=Horux360 <noreply@horuxfin.com>
# ----- Notificaciones admin --------------------------------------------------
ADMIN_EMAIL=carlos@horuxfin.com # destino de "nuevo cliente" + alertas internas
# ----- Facturapi (emisión CFDI) — opcional -----------------------------------
# Sin esto, los tenants no pueden emitir facturas, pero la app arranca.
FACTURAPI_USER_KEY= # sk_user_... (cuenta maestra Horux 360)
# ----- Cloudflare Tunnel (BYO-DB connector) — opcional -----------------------
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_TUNNEL_DOMAIN=tunnel.horux.mx
# ----- KMS para cifrar conexiones BYO-DB y tokens connector ------------------
CONNECTOR_ENCRYPTION_KEY= # generar con `openssl rand -hex 64`
# ----- Metabase (auto-registro BDs tenant para BI) — opcional ----------------
# Sin METABASE_PASSWORD/PG_PASSWORD el service skipea silenciosamente.
METABASE_URL=
METABASE_USERNAME=
METABASE_PASSWORD=
METABASE_PG_HOST=
METABASE_PG_PORT=
METABASE_PG_USER=
METABASE_PG_PASSWORD=
# ----- Cron control en dev (opcional) ----------------------------------------
# ENABLE_CRONS_IN_DEV=1 # activa SAT sync, weekly emails, etc. en NODE_ENV=development
# ----- Watchdog SAT thresholds (opcional, defaults razonables) ---------------
# STALE_PENDING_HOURS=12 # marca pending como failed si nextRetryAt > N h atrás
# STALE_RUNNING_HOURS=4 # marca running como failed si startedAt > N h atrás
# ----- SAT Playwright headless toggle (debug temporal) ----------------------
# SAT_HEADLESS=false # solo dev — muestra browser para debug de scrapers

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
-- AlterEnum
BEGIN;
CREATE TYPE "Plan_new" AS ENUM ('trial', 'custom', 'business_control', 'business_cloud', 'mi_empresa', 'mi_empresa_plus');
ALTER TABLE "tenants" ALTER COLUMN "plan" DROP DEFAULT;
ALTER TABLE "tenants" ALTER COLUMN "plan" TYPE "Plan_new" USING ("plan"::text::"Plan_new");
ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE "Plan_new" USING ("plan"::text::"Plan_new");
ALTER TABLE "subscriptions" ALTER COLUMN "pending_plan" TYPE "Plan_new" USING ("pending_plan"::text::"Plan_new");
ALTER TABLE "subscriptions" ALTER COLUMN "upgrade_target_plan" TYPE "Plan_new" USING ("upgrade_target_plan"::text::"Plan_new");
ALTER TABLE "plan_prices" ALTER COLUMN "plan" TYPE "Plan_new" USING ("plan"::text::"Plan_new");
ALTER TYPE "Plan" RENAME TO "Plan_old";
ALTER TYPE "Plan_new" RENAME TO "Plan";
DROP TYPE "Plan_old";
ALTER TABLE "tenants" ALTER COLUMN "plan" SET DEFAULT 'trial';
COMMIT;
-- AlterTable
ALTER TABLE "tenants" DROP COLUMN "cfdi_limit",
DROP COLUMN "users_limit",
ALTER COLUMN "plan" SET DEFAULT 'trial';

View File

@@ -0,0 +1,55 @@
-- Step 1: Add new columns as nullable (preserva las 4 filas existentes con sus precios)
ALTER TABLE "despacho_plan_prices"
ADD COLUMN "nombre" VARCHAR(50),
ADD COLUMN "max_rfcs" INTEGER,
ADD COLUMN "max_users" INTEGER,
ADD COLUMN "db_mode" "DbMode",
ADD COLUMN "timbres_incluidos_mes" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "permite_servidor_backup" BOOLEAN NOT NULL DEFAULT false;
-- Step 2: Backfill limits para las 4 filas existentes desde el catálogo TS
UPDATE "despacho_plan_prices" SET
"nombre" = 'Mi Empresa',
"max_rfcs" = 1,
"max_users" = 3,
"timbres_incluidos_mes" = 50,
"db_mode" = 'MANAGED'
WHERE "plan" = 'mi_empresa';
UPDATE "despacho_plan_prices" SET
"nombre" = 'Mi Empresa +',
"max_rfcs" = 1,
"max_users" = 3,
"timbres_incluidos_mes" = 50,
"db_mode" = 'MANAGED'
WHERE "plan" = 'mi_empresa_plus';
UPDATE "despacho_plan_prices" SET
"nombre" = 'Business Control',
"max_rfcs" = 100,
"max_users" = -1,
"timbres_incluidos_mes" = 0,
"db_mode" = 'BYO',
"permite_servidor_backup" = true
WHERE "plan" = 'business_control';
UPDATE "despacho_plan_prices" SET
"nombre" = 'Enterprise',
"max_rfcs" = 100,
"max_users" = -1,
"timbres_incluidos_mes" = 0,
"db_mode" = 'BYO',
"permite_servidor_backup" = true
WHERE "plan" = 'business_cloud';
-- Step 3: Set NOT NULL después del backfill (las 4 filas ya están completas)
ALTER TABLE "despacho_plan_prices"
ALTER COLUMN "nombre" SET NOT NULL,
ALTER COLUMN "max_rfcs" SET NOT NULL,
ALTER COLUMN "max_users" SET NOT NULL,
ALTER COLUMN "db_mode" SET NOT NULL;
-- Step 4: Hacer firstYear y renewal nullable para soportar trial y custom (sin precio fijo)
ALTER TABLE "despacho_plan_prices"
ALTER COLUMN "first_year" DROP NOT NULL,
ALTER COLUMN "renewal" DROP NOT NULL;

View File

@@ -0,0 +1,5 @@
-- Drop tabla plan_catalogo (modelo huérfano nunca usado por código activo).
-- Las 2 filas que tenía estaban desincronizadas con el catálogo TS y nunca
-- se referenciaron desde código real. El catálogo despacho vive ahora en
-- `despacho_plan_prices` (extendida con limits en migración 20260430195000).
DROP TABLE "plan_catalogo";

View File

@@ -0,0 +1,10 @@
-- Add column with default false (no-op para filas existentes)
ALTER TABLE "despacho_plan_prices"
ADD COLUMN "permite_sat_incremental" BOOLEAN NOT NULL DEFAULT false;
-- Backfill: planes que SÍ deben tener incremental (3 syncs/día adicionales).
-- Mi Empresa + tiene API + Lolita IA y precio premium ($9k anual);
-- Business Control y Enterprise son los planes despacho con escala alta.
UPDATE "despacho_plan_prices"
SET "permite_sat_incremental" = true
WHERE "plan" IN ('mi_empresa_plus', 'business_control', 'business_cloud');

View File

@@ -0,0 +1,7 @@
-- Cache cifrada de la Live Secret Key de la organización Facturapi del tenant
-- central (Horux 360 admin que emite facturas de subscripción a clientes).
-- AES-256-GCM con derivación FIEL_ENCRYPTION_KEY — mismo patrón que FIEL.
ALTER TABLE "tenants"
ADD COLUMN "facturapi_org_key_enc" BYTEA,
ADD COLUMN "facturapi_org_key_iv" BYTEA,
ADD COLUMN "facturapi_org_key_tag" BYTEA;

View File

@@ -0,0 +1,5 @@
-- Drop tabla plan_prices (modelo legacy Horux 360 sin filas activas).
-- Catálogo se reemplazó por DespachoPlanPrice (despacho_plan_prices) en
-- migración 20260430195000_extend_despacho_plan_prices_with_limits.
-- Sin callers activos en código (verificado vía typecheck post-cleanup).
DROP TABLE "plan_prices";

View File

@@ -0,0 +1,5 @@
-- Tracking de aviso pre-vencimiento por suscripción. Permite que el cron diario
-- evite enviar dos emails del mismo bucket de días al mismo owner.
ALTER TABLE "subscriptions"
ADD COLUMN "last_reminder_day" INTEGER,
ADD COLUMN "last_reminder_sent_at" TIMESTAMP(3);

View File

@@ -0,0 +1,8 @@
-- Preferencias de auto-facturación de pagos de suscripción.
-- factPreferencia: 'publico_general' o 'mis_datos' (default: mis_datos)
-- factUsoCfdi: clave SAT del uso CFDI default (G03 = Gastos en general)
-- factRegimenPreferido: clave del régimen fiscal a usar cuando hay multi-régimen
ALTER TABLE "tenants"
ADD COLUMN "fact_preferencia" VARCHAR(20) DEFAULT 'mis_datos' NOT NULL,
ADD COLUMN "fact_uso_cfdi" VARCHAR(5) DEFAULT 'G03' NOT NULL,
ADD COLUMN "fact_regimen_preferido" VARCHAR(3);

View File

@@ -0,0 +1,4 @@
-- Onboarding auto-dismiss: 4 logins ó pasos completados, lo que pase primero.
ALTER TABLE "users"
ADD COLUMN "login_count" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "onboarding_dismissed_at" TIMESTAMP(3);

View File

@@ -0,0 +1,6 @@
-- Mapa { kindKey: requestId } para reusar requests del SAT en reintentos.
-- Hasta antes de este cambio, cada retry creaba nuevas solicitudes — agotaba
-- la cuota del SAT y abandonaba requests anteriores. Ahora el retry consulta
-- los requestIds previos antes de crear nuevos.
ALTER TABLE "sat_sync_jobs"
ADD COLUMN "sat_request_ids" JSONB NOT NULL DEFAULT '{}'::jsonb;

View File

@@ -0,0 +1,6 @@
-- Distingue extracciones tipo `initial` con rango personalizado (UI custom)
-- de bootstrap inicial puro. Política de retry distinta:
-- initial bootstrap → 3 retries a 6h, 12h, 24h
-- initial custom → 2 retries a 6h, 12h
ALTER TABLE "sat_sync_jobs"
ADD COLUMN "is_custom_range" BOOLEAN NOT NULL DEFAULT false;

View File

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

View File

@@ -0,0 +1,760 @@
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(trial)
databaseName String @unique @map("database_name")
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")
/// Live Secret Key cifrada (AES-256-GCM, misma derivación FIEL_ENCRYPTION_KEY).
/// Cacheada tras primer PUT idempotente a /v2/organizations/{id}/apikeys/live.
facturapiOrgKeyEnc Bytes? @map("facturapi_org_key_enc")
facturapiOrgKeyIv Bytes? @map("facturapi_org_key_iv")
facturapiOrgKeyTag Bytes? @map("facturapi_org_key_tag")
// 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)
// Preferencias de auto-facturación de pagos de suscripción.
// Default: facturar con datos del cliente cuando hay CSF disponible.
// Si `factPreferencia='publico_general'` siempre va a XAXX010101000.
factPreferencia String @default("mis_datos") @map("fact_preferencia") @db.VarChar(20)
// Uso CFDI default cuando se factura con datos del cliente.
// G03 = Gastos en general (más común para SaaS).
factUsoCfdi String @default("G03") @map("fact_uso_cfdi") @db.VarChar(5)
// Si el tenant tiene múltiples regímenes activos, cuál usar para factura.
// Null = usar el primero activo (heurística por createdAt).
factRegimenPreferido String? @map("fact_regimen_preferido") @db.VarChar(3)
// === 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")
// Cuenta sesiones (login exitoso, NO refresh). Usado para auto-dismiss del
// onboarding tras N logins. Default 0 → users pre-rollout siguen viendo el
// onboarding hasta acumular logins post-deploy.
loginCount Int @default(0) @map("login_count")
// Marca explícita de que el onboarding ya no debe mostrarse. Se setea cuando
// el user completa todos los pasos requeridos o desde el endpoint de dismiss.
onboardingDismissedAt DateTime? @map("onboarding_dismissed_at")
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 {
trial
custom
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")
// Idempotencia del cron de aviso pre-vencimiento. Guarda el bucket de días
// que ya se notificó (7, 3, 1 ó 0) para no spamear al owner si el cron corre
// dos veces el mismo día. Se resetea cuando se renueva la suscripción o
// arranca un período nuevo.
lastReminderDay Int? @map("last_reminder_day")
lastReminderSentAt DateTime? @map("last_reminder_sent_at")
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")
}
/// Catálogo despacho — precios + limits editables por admin global.
/// Las `features` siguen viviendo en TS (`DESPACHO_PLANS` en `@horux/shared`)
/// porque están acopladas a UI/middleware y son contrato de código.
/// Incluye filas para `trial` y `custom` (sin precios — null).
model DespachoPlanPrice {
plan String @id // trial | custom | mi_empresa | mi_empresa_plus | business_control | business_cloud
nombre String @db.VarChar(50)
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")
/// Limits del plan. -1 = ilimitado donde aplique (maxUsers).
maxRfcs Int @map("max_rfcs")
maxUsers Int @map("max_users")
timbresIncluidosMes Int @default(0) @map("timbres_incluidos_mes")
dbMode DbMode @map("db_mode")
permiteServidorBackup Boolean @default(false) @map("permite_servidor_backup")
/// Habilita SAT incremental (3 syncs/día adicionales al daily). Mi Empresa +,
/// Business Control y Enterprise lo tienen activo por default; planes
/// inferiores se quedan solo con el daily de las 03:00.
permiteSatIncremental Boolean @default(false) @map("permite_sat_incremental")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("despacho_plan_prices")
}
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)
// Mapa { kindKey: requestId } de TODOS los requests creados durante el job.
// Permite que retries reusen requestIds previos en lugar de quemar cuota
// del SAT creando nuevos. kindKey = `${requestType}-${tipoCfdi}-${from}-${to}`.
satRequestIds Json @default("{}") @map("sat_request_ids")
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")
// True cuando el job es `initial` con rango de fechas personalizado por el
// usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del
// bootstrap puro. Daily/incremental ignoran este campo.
isCustomRange Boolean @default(false) @map("is_custom_range")
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")
}

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

@@ -0,0 +1,528 @@
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`);
// Tabla `plan_prices` (modelo PlanPrice) era el catálogo Horux 360 legacy.
// Tras eliminar los planes legacy (2026-04-30), no se siembran filas. Los
// precios despacho viven en `despacho_plan_prices` (modelo DespachoPlanPrice).
// Catálogo despacho — precios + limits. UPSERT idempotente: precios y limits
// se actualizan al re-correr seed (decisión: el seed es source of truth para
// valores iniciales; el admin puede sobreescribir vía UI y NO debe re-correr
// seed si no quiere perder ajustes manuales). Si quieres preservar edits del
// admin, cambiar `update` a `{}` y aplicar manualmente.
const DESPACHO_PLAN_CATALOGO = [
{ plan: 'trial', nombre: 'Prueba', monthly: null, firstYear: null, renewal: null, permiteMonthly: false, maxRfcs: 3, maxUsers: 1, timbresIncluidosMes: 0, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
{ plan: 'custom', nombre: 'Custom', monthly: null, firstYear: null, renewal: null, permiteMonthly: false, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
{ plan: 'mi_empresa', nombre: 'Mi Empresa', monthly: 580, firstYear: 5800, renewal: 5800, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
{ plan: 'mi_empresa_plus', nombre: 'Mi Empresa +', monthly: 900, firstYear: 9000, renewal: 9000, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: true },
{ plan: 'business_control', nombre: 'Business Control', monthly: null, firstYear: 25850, renewal: 25850, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true },
{ plan: 'business_cloud', nombre: 'Enterprise', monthly: null, firstYear: 43000, renewal: 43000, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true },
];
for (const p of DESPACHO_PLAN_CATALOGO) {
await prisma.despachoPlanPrice.upsert({
where: { plan: p.plan },
update: { ...p },
create: { ...p },
});
}
console.log(`${DESPACHO_PLAN_CATALOGO.length} planes despacho cargados (precios + limits)`);
// 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: 'mi_empresa_plus',
databaseName,
},
});
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. Filtramos por
// longitud porque trial_usages.rfc es varchar(13) y los tenants despacho usan
// slugs largos (DESPACHO_xxx) que no encajan — el padrón anti-abuso de trial
// solo aplica a RFCs SAT reales de personas/empresas, no a slugs.
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 AND LENGTH(rfc) <= 13
ON CONFLICT (rfc) DO NOTHING
`);
// Backfill de user_platform_roles: los owners del tenant HTS240708LJA se
// convierten automáticamente en platform_admin. Migrado a tenant_memberships
// tras F6 (User.tenantId/rolId eliminados). Idempotente.
await prisma.$executeRawUnsafe(`
INSERT INTO user_platform_roles (user_id, role, created_at)
SELECT tm.user_id, 'platform_admin'::"PlatformRole", NOW()
FROM tenant_memberships tm
JOIN tenants t ON tm.tenant_id = t.id
WHERE t.rfc = 'HTS240708LJA' AND tm.is_owner = true AND tm.active = true
ON CONFLICT (user_id, role) DO NOTHING
`);
// (Backfill de tenant_memberships eliminado — F6 ya migró todos los users
// legacy y los campos `User.tenantId` y `User.rolId` ya no existen. Los
// users nuevos se crean directamente con su membership.)
// 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 rolId = rolMap.get(userData.rolNombre)!;
const user = await prisma.user.upsert({
where: { email: userData.email },
update: {},
create: {
email: userData.email,
passwordHash,
nombre: userData.nombre,
lastTenantId: tenant.id,
},
});
// Membership al tenant demo (idempotente — F6 multi-tenant: la autorización
// vive en tenant_memberships, no en User.tenantId/rolId).
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
update: { rolId, isOwner: userData.rolNombre === 'owner' || userData.rolNombre === 'cfo', active: true },
create: {
userId: user.id,
tenantId: tenant.id,
rolId,
isOwner: userData.rolNombre === 'owner' || userData.rolNombre === 'cfo',
active: true,
},
});
console.log(`✅ User created: ${user.email} (${userData.rolNombre})`);
}
// 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');
// Sin ON CONFLICT: las tablas se dropean en línea 342-352 antes de seed
// y los UUIDs son crypto.randomUUID() (probabilidad de colisión ~0).
// El UNIQUE en cfdis es funcional (LOWER(uuid)), no acepta ON CONFLICT
// por columna plana — ver migración 027_cfdi_uuid_unique_case_insensitive.
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)
`, [
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();
}
// (PlanCatalogo seed eliminado — el modelo se dropeó en migración
// 20260430200000_drop_plan_catalogo_orphan; el catálogo despacho vive en
// `despacho_plan_prices` y se siembra arriba en DESPACHO_PLAN_CATALOGO.)
// Seed addon catalog
const addonCatalogoData = [
{
codename: 'rfcs_extra_10',
nombre: '+10 RFCs adicionales',
verticalProfile: 'CONTABLE' as const,
precio: 190,
frecuencia: 'mensual',
delta: { maxRfcs: 10 },
},
{
codename: 'rfcs_extra_50',
nombre: '+50 RFCs adicionales',
verticalProfile: 'CONTABLE' as const,
precio: 690,
frecuencia: 'mensual',
delta: { maxRfcs: 50 },
},
{
codename: 'timbres_extra_500',
nombre: '+500 timbres mensuales',
precio: 490,
frecuencia: 'mensual',
delta: { timbresIncluidosMes: 500 },
},
{
codename: 'modulo_ia',
nombre: 'Módulo IA Fiscal',
precio: 390,
frecuencia: 'mensual',
delta: { features: ['ia_lolita'] },
},
{
// Lolita IA activable por contribuyente específico del despacho.
// SubscriptionAddon.contribuyenteId apunta al RFC que lo contrata.
// Cobro mensual en preapproval propio (la licencia del despacho es anual;
// el add-on va en ciclo independiente).
codename: 'lolita_ia_contribuyente',
nombre: 'Lolita IA (por contribuyente)',
verticalProfile: 'CONTABLE' as const,
precio: 250,
frecuencia: 'mensual',
delta: { features: ['ia_lolita'] },
},
{
// Contribuyente adicional para planes Business Control y Enterprise
// (ambos incluyen 100 base). Se cobra automáticamente según overage; no
// requiere opt-in, pero se modela como add-on para que el preapproval MP
// lo cubra. El codename mantiene el sufijo "business_cloud" por compat
// con suscripciones existentes; el nombre display ya es genérico.
codename: 'contribuyente_extra_business_cloud',
nombre: 'Contribuyente adicional (RFC extra)',
verticalProfile: 'CONTABLE' as const,
precio: 45,
frecuencia: 'mensual',
delta: { maxRfcs: 1 },
},
];
for (const a of addonCatalogoData) {
await prisma.planAddonCatalogo.upsert({
where: { codename: a.codename },
update: { nombre: a.nombre, precio: a.precio, delta: a.delta },
create: { ...a, verticalProfile: a.verticalProfile ?? null },
});
}
console.log('✓ Addon catalog seeded (6 addons)');
console.log('\n🎉 Seed completed successfully!');
console.log('\n📝 Demo credentials:');
console.log(' Admin: admin@demo.com / demo123');
console.log(' Contador: contador@demo.com / demo123');
console.log(' Visor: visor@demo.com / demo123');
}
main()
.catch((e) => {
console.error('Error seeding database:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,37 @@
/**
* Aplica la migración 042 (ncs_emitidas + ncs_recibidas) a todos los tenants.
* Idempotente — ADD COLUMN IF NOT EXISTS no falla si ya existe.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { migrate } from '../src/config/tenant-migrations.js';
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true, nombre: true },
orderBy: { rfc: 'asc' },
});
console.log(`Aplicando migraciones a ${tenants.length} tenants...\n`);
let ok = 0;
let failed = 0;
for (const t of tenants) {
try {
const pool = await tenantDb.getPool(t.id, t.databaseName);
await migrate(pool);
console.log(`${t.rfc.padEnd(25)} ${t.nombre}`);
ok++;
} catch (err: any) {
console.error(`${t.rfc.padEnd(25)} ${t.nombre}${err.message || err}`);
failed++;
}
}
console.log(`\nCompletado: ${ok} OK, ${failed} fallidos`);
await prisma.$disconnect();
process.exit(failed > 0 ? 1 : 0);
}
main().catch(e => { console.error(e); process.exit(1); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
/**
* Backfill: re-parsea CFDIs tipo P emitidos vía Facturapi (source='facturapi')
* que tienen `monto_pago_mxn` o `fecha_pago_p` NULL, y popula esos campos
* desde el XML original. Bug histórico — el INSERT de facturapi.controller.ts
* no incluía los campos del complemento Pagos hasta el fix de hoy.
*
* Idempotente — solo actualiza si el XML tiene datos y el row tiene NULL.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { parseXml } from '../src/services/sat/sat-parser.service.js';
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, nombre: true, databaseName: true },
});
let totalUpdated = 0;
let totalChecked = 0;
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows } = await pool.query(`
SELECT uuid, xml_original
FROM cfdis
WHERE source = 'facturapi'
AND tipo_comprobante = 'P'
AND xml_original IS NOT NULL
AND (monto_pago_mxn IS NULL OR fecha_pago_p IS NULL)
`);
if (rows.length === 0) continue;
console.log(`\n>>> ${t.rfc} (${t.nombre}): ${rows.length} P por backfill`);
for (const r of rows) {
totalChecked++;
const parsed = parseXml(r.xml_original, 'emitidos');
if (!parsed || parsed.tipoComprobante !== 'P') continue;
const fechaPagoP = parsed.fechaPagoP
? new Date(String(parsed.fechaPagoP).split('|')[0])
: null;
if (!parsed.montoPago && !fechaPagoP) {
console.log(` ${r.uuid}: XML sin datos de Pago — skip`);
continue;
}
await pool.query(`
UPDATE cfdis SET
monto_pago = COALESCE(monto_pago, $1),
monto_pago_mxn = COALESCE(monto_pago_mxn, $1),
fecha_pago_p = COALESCE(fecha_pago_p, $2),
iva_traslado_pago = COALESCE(iva_traslado_pago, $3),
iva_traslado_pago_mxn = COALESCE(iva_traslado_pago_mxn, $3),
iva_retencion_pago = COALESCE(iva_retencion_pago, $4),
iva_retencion_pago_mxn = COALESCE(iva_retencion_pago_mxn, $4),
ieps_traslado_pago = COALESCE(ieps_traslado_pago, $5),
ieps_traslado_pago_mxn = COALESCE(ieps_traslado_pago_mxn, $5)
WHERE uuid = $6
`, [
parsed.montoPago || 0,
fechaPagoP,
parsed.ivaTrasladoPago || 0,
parsed.ivaRetencionPago || 0,
parsed.iepsTrasladoPago || 0,
r.uuid,
]);
totalUpdated++;
console.log(` ${r.uuid}: ✓ monto=$${parsed.montoPago} fecha_pago=${fechaPagoP?.toISOString().slice(0, 10)} iva=$${parsed.ivaTrasladoPago}`);
}
}
console.log(`\n[Backfill] Completado: ${totalUpdated}/${totalChecked} actualizadas`);
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

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

View File

@@ -0,0 +1,131 @@
/**
* 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 = 'custom' as const;
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} (sin cobro — admin global)`);
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,
adminEmail,
adminNombre,
amount: 0,
});
console.log(`✓ Tenant creado: ${tenant.id}`);
console.log(`✓ BD provisionada: ${tenant.databaseName}`);
console.log(`✓ Carlos creado (owner): ${carlosUser.email}`);
// 2. Asigna platform_admin a Carlos (no se hace automáticamente desde tenants.service)
const carlosFull = await prisma.user.findUnique({ where: { email: adminEmail } });
if (carlosFull) {
await prisma.userPlatformRole.upsert({
where: { userId_role: { userId: carlosFull.id, role: 'platform_admin' } },
update: {},
create: { userId: carlosFull.id, role: 'platform_admin' },
});
console.log(`✓ Carlos: rol platform_admin asignado`);
}
// 3. Crea Ivan como contador del tenant (membership) y le asigna platform_ti
const ivan = await usuariosService.inviteUsuario(tenant.id, {
email: tiEmail,
nombre: tiNombre,
role: 'contador',
});
console.log(`✓ Ivan creado: ${ivan.email} (membership contador)`);
await prisma.userPlatformRole.upsert({
where: { userId_role: { userId: ivan.id, role: 'platform_ti' } },
update: {},
create: { userId: ivan.id, role: 'platform_ti' },
});
console.log(`✓ Ivan: rol platform_ti asignado (superset, mismos permisos que admin)`);
// 4. Sube la subscription a 'authorized' con vigencia de 1 año
const existing = await prisma.subscription.findFirst({
where: { tenantId: tenant.id },
orderBy: { createdAt: 'desc' },
});
if (existing) {
const now = new Date();
const end = new Date(now);
end.setFullYear(end.getFullYear() + SUBSCRIPTION_YEARS);
await prisma.subscription.update({
where: { id: existing.id },
data: {
status: 'authorized',
currentPeriodStart: now,
currentPeriodEnd: end,
},
});
console.log(`✓ Suscripción marcada 'authorized' hasta ${end.toISOString().slice(0, 10)}`);
}
console.log('');
console.log('=== DONE ===');
console.log(`Credenciales temporales para primer login:`);
console.log(` Carlos (admin): ${adminEmail}`);
console.log(` Password: ${carlosPassword}`);
console.log('');
console.log(` Ivan (TI): ${tiEmail}`);
console.log(` Password: revisa el correo de bienvenida (inviteUsuario lo envía por email)`);
console.log('');
console.log('Próximos pasos manuales:');
console.log(` 1. Carlos login en /login con las credenciales de arriba`);
console.log(` 2. Cambiar el password desde /configuracion/seguridad`);
console.log(` 3. Verificar que Ivan recibió su correo de invitación`);
console.log(` 4. Subir FIEL en /configuracion/sat para habilitar sincronización`);
console.log(` 5. (Opcional) Configurar organización Facturapi en /configuracion`);
}
main()
.catch((err) => {
console.error('✗ Bootstrap falló:', err.message || err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,103 @@
/**
* Inspecciona un CFDI específico para entender por qué el filtro de
* "Considerar activos" no lo captura. Imprime los campos relevantes y
* cualquier CFDI relacionado.
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const uuid = process.argv[2] || '8ec2eaf3-7879-11f0-81a8-8daae9822b10';
// Buscar en TODOS los tenants
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, nombre: true, databaseName: true },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows } = await pool.query(`
SELECT
uuid, type, tipo_comprobante, metodo_pago, forma_pago,
uso_cfdi, cfdi_tipo_relacion,
rfc_emisor, nombre_emisor, regimen_fiscal_emisor,
rfc_receptor, nombre_receptor, regimen_fiscal_receptor,
total_mxn, monto_pago_mxn,
fecha_emision, fecha_pago_p, status,
uuid_relacionado, cfdis_relacionados
FROM cfdis
WHERE uuid = $1
`, [uuid]);
if (rows.length === 0) continue;
console.log(`\n═══ Tenant: ${t.rfc} (${t.nombre}) ═══`);
const r = rows[0];
for (const [k, v] of Object.entries(r)) {
console.log(` ${k.padEnd(28)} ${v}`);
}
// Si hay uuid_relacionado o cfdis_relacionados, traer esos también
if (r.uuid_relacionado) {
const { rows: rel } = await pool.query(
`SELECT uuid, tipo_comprobante, uso_cfdi, total_mxn FROM cfdis WHERE LOWER(uuid) = LOWER($1)`,
[r.uuid_relacionado],
);
console.log(`\n Relacionado vía uuid_relacionado (${r.uuid_relacionado}):`);
console.log(rel[0] || '(no encontrado)');
}
if (r.cfdis_relacionados) {
const uuids = String(r.cfdis_relacionados).split('|').map(s => s.trim()).filter(Boolean);
console.log(`\n Relacionados vía cfdis_relacionados (${uuids.length}):`);
for (const u of uuids) {
const { rows: rel } = await pool.query(
`SELECT uuid, tipo_comprobante, uso_cfdi, total_mxn FROM cfdis WHERE LOWER(uuid) = LOWER($1)`,
[u],
);
console.log(` ${u}`, rel[0] || '(no encontrado)');
}
}
// Test del filtro: aplica activosExclusionNoAlias y verifica
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
const test = await pool.query(`
SELECT
(tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}) AS regla1_directo,
(tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
)) AS regla2_p_paga_activo,
(tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
SELECT 1 FROM cfdis r_act
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
AND (r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
)) AS regla3_e_referencia_activo,
(tipo_comprobante = 'I' AND EXISTS (
SELECT 1 FROM cfdis i07_act
WHERE i07_act.tipo_comprobante = 'I'
AND i07_act.metodo_pago = 'PPD'
AND COALESCE(i07_act.cfdi_tipo_relacion, '') = '07'
AND i07_act.uso_cfdi IN ${ACTIVOS_USOS}
AND i07_act.status NOT IN ('Cancelado', '0')
AND i07_act.cfdis_relacionados IS NOT NULL
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(i07_act.cfdis_relacionados), '|'))
)) AS regla4_anticipo_activo
FROM cfdis
WHERE uuid = $1
`, [uuid]);
console.log(`\n Filtro activos:`);
console.log(` regla1 (I directo activo): ${test.rows[0].regla1_directo}`);
console.log(` regla2 (P paga I activo): ${test.rows[0].regla2_p_paga_activo}`);
console.log(` regla3 (E ref. I/P activo): ${test.rows[0].regla3_e_referencia_activo}`);
console.log(` regla4 (anticipo de I/07 act): ${test.rows[0].regla4_anticipo_activo}`);
const filtrado = test.rows[0].regla1_directo || test.rows[0].regla2_p_paga_activo || test.rows[0].regla3_e_referencia_activo || test.rows[0].regla4_anticipo_activo;
console.log(`${filtrado ? '🔴 FILTRADO (excluido del cálculo)' : '🟢 PASA (incluido en cálculo)'}`);
}
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,154 @@
/**
* Diseca cómo el CFDI 8ec2eaf3-7879-11f0-81a8-8daae9822b10 (P de pago $295,100,
* uuid_relacionado → I de activo I03) se compensa en el cálculo de deducciones
* de Husberto en agosto 2025, con considerarActivos=true vs false.
*
* Reproduce las queries reales de calcularEgresosPorRegimen para mostrar
* el aporte categoría por categoría.
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' } });
if (!t) { console.log('Patito tenant not found'); return; }
const pool = await tenantDb.getPool(t.id, t.databaseName);
const RFC = 'TOAH680201RA2';
const FI = '2025-08-01';
const FF = '2025-08-31';
const TARGET_UUID = '8ec2eaf3-7879-11f0-81a8-8daae9822b10';
// ───────────────────────────────────────────────────────────────────────────
// 0) Datos del CFDI target
// ───────────────────────────────────────────────────────────────────────────
const { rows: [cfdi] } = await pool.query(`
SELECT uuid, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
total_mxn, monto_pago_mxn, iva_traslado_pago_mxn, ieps_traslado_pago_mxn,
uuid_relacionado, regimen_fiscal_receptor, fecha_pago_p
FROM cfdis WHERE uuid = $1
`, [TARGET_UUID]);
console.log('═══ CFDI target ═══');
console.log(` UUID: ${cfdi.uuid}`);
console.log(` Tipo: ${cfdi.tipo_comprobante} (${cfdi.uso_cfdi || '?'})`);
console.log(` monto_pago_mxn: $${cfdi.monto_pago_mxn}`);
console.log(` iva_traslado_pago: $${cfdi.iva_traslado_pago_mxn ?? 'null'}`);
console.log(` ieps_traslado_pago: $${cfdi.ieps_traslado_pago_mxn ?? 'null'}`);
console.log(` forma_pago: ${cfdi.forma_pago ?? 'NULL'}`);
console.log(` uuid_relacionado: ${cfdi.uuid_relacionado}`);
console.log(` fecha_pago_p: ${cfdi.fecha_pago_p}`);
// Net pagado según fórmula de deducciones (P)
const monto = Number(cfdi.monto_pago_mxn || 0);
const ivaPago = Number(cfdi.iva_traslado_pago_mxn || 0);
const iepsPago = Number(cfdi.ieps_traslado_pago_mxn || 0);
const ivaClamped = Math.min(ivaPago, monto * 0.16);
const netoP = monto - ivaClamped - iepsPago;
console.log(`\n → Aporte neto a deducciones (formula P): $${netoP.toFixed(2)}`);
console.log(` monto - LEAST(iva, monto*0.16) - ieps = ${monto} - ${ivaClamped.toFixed(2)} - ${iepsPago}`);
// CFDI relacionado
const { rows: [rel] } = await pool.query(`
SELECT uuid, tipo_comprobante, metodo_pago, uso_cfdi, total_mxn, cfdi_tipo_relacion
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
`, [cfdi.uuid_relacionado]);
if (rel) {
console.log(`\n uuid_relacionado apunta a:`);
console.log(` ${rel.uuid} | ${rel.tipo_comprobante} ${rel.metodo_pago} | uso_cfdi=${rel.uso_cfdi} | total=$${rel.total_mxn}`);
console.log(` cfdi_tipo_relacion: ${rel.cfdi_tipo_relacion ?? 'null'}`);
console.log(` ¿es activo? uso_cfdi=${rel.uso_cfdi}${['I01','I02','I03','I04','I05','I06','I07','I08'].includes(rel.uso_cfdi) ? '🔴 SÍ' : '🟢 NO'}`);
}
// ───────────────────────────────────────────────────────────────────────────
// 1) Predicado de filtros
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ Evaluación de predicados sobre el CFDI target ═══');
const ACTIVOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
const t1 = await pool.query(`
SELECT
(COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000) AS no_deducible_efectivo,
(tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS}
)) AS p_paga_activo
FROM cfdis WHERE uuid = $1
`, [TARGET_UUID]);
console.log(` no_deducible_efectivo (forma_pago=01 AND >2k): ${t1.rows[0].no_deducible_efectivo ? '🔴 TRUE' : '🟢 FALSE'}`);
console.log(` p_paga_activo (regla activos): ${t1.rows[0].p_paga_activo ? '🔴 TRUE' : '🟢 FALSE'}`);
// ───────────────────────────────────────────────────────────────────────────
// 2) Total de deducciones de Husberto en agosto, con/sin filtro de activos
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ Suma TOTAL de deducciones (régimen 612 — Husberto, agosto 2025) ═══');
const sumar = async (extraSQL: string) => {
// I PUE
const { rows: [iPUE] } = await pool.query(`
SELECT COALESCE(SUM(COALESCE(total_mxn,0) - COALESCE(iva_traslado_mxn,0) - COALESCE(ieps_traslado_mxn,0) - COALESCE(impuestos_locales_trasladado_mxn,0)),0)::numeric(14,2) as monto
FROM cfdis
WHERE UPPER(rfc_receptor) = $1 AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $2::date AND fecha_emision < ($3::date + interval '1 day')
AND NOT (COALESCE(forma_pago,'') = '01' AND COALESCE(total_mxn,0) > 2000)
${extraSQL}
`, [RFC, FI, FF]);
// P
const { rows: [pCfdis] } = await pool.query(`
SELECT COALESCE(SUM(COALESCE(monto_pago_mxn,0) - LEAST(COALESCE(iva_traslado_pago_mxn,0), COALESCE(monto_pago_mxn,0)*0.16) - COALESCE(ieps_traslado_pago_mxn,0)),0)::numeric(14,2) as monto
FROM cfdis
WHERE UPPER(rfc_receptor) = $1 AND tipo_comprobante = 'P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
AND NOT (COALESCE(forma_pago,'') = '01' AND COALESCE(monto_pago_mxn,0) > 2000)
${extraSQL}
`, [RFC, FI, FF]);
return { iPUE: Number(iPUE.monto), p: Number(pCfdis.monto) };
};
const ACTIVOS_FILTER = `
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS})
AND NOT (tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS}
))
AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
SELECT 1 FROM cfdis r_act
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
AND (
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS})
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis pi_act
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
AND pi_act.tipo_comprobante = 'I'
AND pi_act.uso_cfdi IN ${ACTIVOS}
))
)
))
`;
const ON = await sumar('');
const OFF = await sumar(ACTIVOS_FILTER);
console.log(`\n┌──────────────────┬────────────────┬────────────────┬────────────────┐`);
console.log(`│ Categoría │ Activos ON │ Activos OFF │ Diferencia │`);
console.log(`├──────────────────┼────────────────┼────────────────┼────────────────┤`);
console.log(`│ I PUE recibidas │ $${String(ON.iPUE.toFixed(2)).padStart(13)}$${String(OFF.iPUE.toFixed(2)).padStart(13)}$${String((ON.iPUE-OFF.iPUE).toFixed(2)).padStart(13)}`);
console.log(`│ P recibidos │ $${String(ON.p.toFixed(2)).padStart(13)}$${String(OFF.p.toFixed(2)).padStart(13)}$${String((ON.p-OFF.p).toFixed(2)).padStart(13)}`);
console.log(`├──────────────────┼────────────────┼────────────────┼────────────────┤`);
const totON = ON.iPUE + ON.p;
const totOFF = OFF.iPUE + OFF.p;
console.log(`│ TOTAL deducción │ $${String(totON.toFixed(2)).padStart(13)}$${String(totOFF.toFixed(2)).padStart(13)}$${String((totON-totOFF).toFixed(2)).padStart(13)}`);
console.log(`└──────────────────┴────────────────┴────────────────┴────────────────┘`);
console.log(`\n→ El CFDI ${TARGET_UUID.slice(0,8)} aporta $${netoP.toFixed(2)} a "P recibidos" cuando ON, $0 cuando OFF`);
console.log(` (Su exclusión por activos representa el ${((netoP / (ON.p - OFF.p)) * 100).toFixed(0)}% de la diferencia en P recibidos)`);
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,111 @@
/**
* Reproduce el cálculo de deducciones para Husberto en agosto 2025 con
* considerarActivos=true vs false, y muestra la diferencia esperada.
* Apunta directo al SQL para descartar bugs de wire/cache/UI.
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' } });
if (!t) { console.log('Patito tenant not found'); return; }
const pool = await tenantDb.getPool(t.id, t.databaseName);
const RFC = 'TOAH680201RA2';
const FI = '2025-08-01';
const FF = '2025-08-31';
console.log(`Husberto (${RFC}), agosto 2025\n`);
// 0) Lista TODOS los P recibidos en el período (sin filtros)
const all = await pool.query(`
SELECT uuid, monto_pago_mxn, forma_pago, fecha_pago_p, uuid_relacionado
FROM cfdis
WHERE UPPER(rfc_receptor) = $1
AND tipo_comprobante = 'P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
ORDER BY monto_pago_mxn DESC
`, [RFC, FI, FF]);
console.log(`Total P recibidos en agosto 2025 (sin filtros): ${all.rows.length}`);
for (const r of all.rows) {
console.log(` ${r.uuid} | $${r.monto_pago_mxn} | forma_pago=${r.forma_pago} | uuid_rel=${r.uuid_relacionado}`);
}
console.log();
// 1) Suma de P recibidos sin filtro extra
const sinFiltro = await pool.query(`
SELECT COUNT(*)::int as n,
COALESCE(SUM(COALESCE(monto_pago_mxn,0)),0)::numeric(14,2) as bruto,
COALESCE(SUM(COALESCE(monto_pago_mxn,0) - LEAST(COALESCE(iva_traslado_pago_mxn,0), COALESCE(monto_pago_mxn,0)*0.16) - COALESCE(ieps_traslado_pago_mxn,0)),0)::numeric(14,2) as neto
FROM cfdis
WHERE UPPER(rfc_receptor) = $1
AND tipo_comprobante = 'P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
AND NOT (COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)
`, [RFC, FI, FF]);
console.log(`P recibidos SIN filtro activos (CON filtro no-deducible): n=${sinFiltro.rows[0].n}, bruto=$${sinFiltro.rows[0].bruto}, neto=$${sinFiltro.rows[0].neto}`);
// 2) Misma query CON el filtro de activos (regla 2: P paga I de activo)
const ACTIVOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
const conFiltro = await pool.query(`
SELECT COUNT(*)::int as n,
COALESCE(SUM(COALESCE(monto_pago_mxn,0)),0)::numeric(14,2) as bruto,
COALESCE(SUM(COALESCE(monto_pago_mxn,0) - LEAST(COALESCE(iva_traslado_pago_mxn,0), COALESCE(monto_pago_mxn,0)*0.16) - COALESCE(ieps_traslado_pago_mxn,0)),0)::numeric(14,2) as neto
FROM cfdis
WHERE UPPER(rfc_receptor) = $1
AND tipo_comprobante = 'P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
AND NOT (COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS})
AND NOT (tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS}
))
AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
SELECT 1 FROM cfdis r_act
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
AND (
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS})
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis pi_act
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
AND pi_act.tipo_comprobante = 'I'
AND pi_act.uso_cfdi IN ${ACTIVOS}
))
)
))
`, [RFC, FI, FF]);
console.log(`P recibidos CON filtro activos: n=${conFiltro.rows[0].n}, bruto=$${conFiltro.rows[0].bruto}, neto=$${conFiltro.rows[0].neto}`);
console.log(`\n→ Diferencia esperada al desactivar Considerar Activos:`);
console.log(` Bruto: $${(Number(sinFiltro.rows[0].bruto) - Number(conFiltro.rows[0].bruto)).toLocaleString('es-MX')}`);
console.log(` Neto: $${(Number(sinFiltro.rows[0].neto) - Number(conFiltro.rows[0].neto)).toLocaleString('es-MX')}`);
// 3) Lista los P específicos que se filtran
console.log(`\nDetalle de P que SE FILTRAN al desactivar activos:`);
const filtrados = await pool.query(`
SELECT uuid, monto_pago_mxn, iva_traslado_pago_mxn, uuid_relacionado, fecha_pago_p
FROM cfdis
WHERE UPPER(rfc_receptor) = $1
AND tipo_comprobante = 'P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS}
)
`, [RFC, FI, FF]);
for (const r of filtrados.rows) {
console.log(` ${r.uuid} | $${r.monto_pago_mxn} → uuid_rel: ${r.uuid_relacionado}`);
}
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,71 @@
/**
* Ejecuta los 3 nuevos buckets de drill-down (ncs_emitidas, ncs_recibidas,
* no_deducibles_efectivo) directamente contra una BD tenant para verificar
* que cada uno produce resultados distintos. Sirve para descartar hipótesis
* de bug en frontend / cache / dev server stale.
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const rfc = process.argv[2] || 'DESPACHO_MO7JE8BZ_VDOPR';
const fi = process.argv[3] || '2025-08-01';
const ff = process.argv[4] || '2025-08-31';
const t = await prisma.tenant.findFirst({ where: { rfc } });
if (!t) { console.log('Tenant', rfc, 'no encontrado'); return; }
const pool = await tenantDb.getPool(t.id, t.databaseName);
console.log(`Tenant: ${rfc} — Período: ${fi}${ff}\n`);
const buckets = [
{
name: 'ncs_emitidas',
sql: `
SELECT COUNT(*)::int as n, COALESCE(SUM(total_mxn),0)::numeric(14,2) as total
FROM cfdis
WHERE type = 'EMITIDO'
AND tipo_comprobante = 'E' 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_emisor IS NOT NULL
`,
},
{
name: 'ncs_recibidas',
sql: `
SELECT COUNT(*)::int as n, COALESCE(SUM(total_mxn),0)::numeric(14,2) as total
FROM cfdis
WHERE type = 'RECIBIDO'
AND tipo_comprobante = 'E' 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 IS NOT NULL
`,
},
{
name: 'no_deducibles_efectivo',
sql: `
SELECT COUNT(*)::int as n, COALESCE(SUM(total_mxn),0)::numeric(14,2) as total
FROM cfdis
WHERE type = 'RECIBIDO'
AND forma_pago = '01'
AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND COALESCE(total_mxn, 0) > 2000)
OR (tipo_comprobante = 'P' AND COALESCE(monto_pago_mxn, 0) > 2000)
)
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
AND regimen_fiscal_receptor IS NOT NULL
`,
},
];
for (const b of buckets) {
const { rows: [r] } = await pool.query(b.sql, [fi, ff]);
console.log(`${b.name.padEnd(28)}${r.n} fila(s), total = $${Number(r.total).toLocaleString('es-MX')}`);
}
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,169 @@
/**
* Diseca cómo se compensa la I PPD con cfdi_tipo_relacion='07' (aplicación
* de anticipo) en el cálculo de deducciones, evaluando:
* - Si NO entra al sumatorio normal (I PUE / P) por ser PPD
* - Si entra a la compensación I/07 PPD ↔ E del mismo mes
* - Si tiene cfdis_relacionados (qué referencia hacia atrás)
* - Si es referenciada por algún CFDI hacia adelante (P, E, otra I)
* - Cómo afecta con considerarActivos ON vs OFF
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' } });
if (!t) { console.log('Patito tenant not found'); return; }
const pool = await tenantDb.getPool(t.id, t.databaseName);
const TARGET = '5c874749-748f-11f0-96b1-2b9310891836';
const RFC = 'TOAH680201RA2';
// ───────────────────────────────────────────────────────────────────────────
// 0) Datos del CFDI
// ───────────────────────────────────────────────────────────────────────────
const { rows: [c] } = await pool.query(`
SELECT uuid, type, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
cfdi_tipo_relacion, cfdis_relacionados,
total_mxn, iva_traslado_mxn, ieps_traslado_mxn,
rfc_emisor, nombre_emisor, regimen_fiscal_emisor,
rfc_receptor, nombre_receptor, regimen_fiscal_receptor,
fecha_emision, fecha_pago_p, status,
saldo_pendiente_mxn
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
`, [TARGET]);
console.log('═══ CFDI ═══');
for (const [k, v] of Object.entries(c)) {
console.log(` ${k.padEnd(28)} ${v}`);
}
// ───────────────────────────────────────────────────────────────────────────
// 1) ¿Hace referencia hacia atrás (vía cfdis_relacionados)?
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ Referencias hacia atrás (cfdis_relacionados) ═══');
if (!c.cfdis_relacionados) {
console.log(' (ninguna — cfdis_relacionados es NULL)');
} else {
const uuids = String(c.cfdis_relacionados).split('|').map((s: string) => s.trim()).filter(Boolean);
for (const u of uuids) {
const { rows: [rel] } = await pool.query(`
SELECT uuid, tipo_comprobante, metodo_pago, total_mxn, fecha_emision, cfdi_tipo_relacion
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
`, [u]);
console.log(` ${u}`, rel ?? '(no encontrado)');
}
}
// ───────────────────────────────────────────────────────────────────────────
// 2) ¿Es referenciada hacia adelante? (P que la pague, E que la cancele, otra I tipo_relacion=07 que sustituya)
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ CFDIs que referencian a este (hacia adelante) ═══');
// P que la pagan vía uuid_relacionado
const { rows: pagos } = await pool.query(`
SELECT uuid, monto_pago_mxn, fecha_pago_p
FROM cfdis
WHERE tipo_comprobante = 'P'
AND LOWER(uuid_relacionado) = LOWER($1)
AND status NOT IN ('Cancelado','0')
ORDER BY fecha_pago_p
`, [TARGET]);
console.log(` P que la pagan (${pagos.length}):`);
let totalPagado = 0;
for (const p of pagos) {
totalPagado += Number(p.monto_pago_mxn || 0);
console.log(` ${p.uuid} | $${p.monto_pago_mxn} | ${p.fecha_pago_p}`);
}
console.log(` → Total pagado vía P: $${totalPagado.toLocaleString('es-MX')}`);
console.log(` Total CFDI original: $${c.total_mxn}`);
console.log(` Saldo pendiente: $${c.saldo_pendiente_mxn ?? '?'}`);
// E que la cancelan vía cfdis_relacionados
const { rows: ecanc } = await pool.query(`
SELECT uuid, tipo_comprobante, metodo_pago, total_mxn, cfdi_tipo_relacion, fecha_emision
FROM cfdis
WHERE tipo_comprobante = 'E'
AND cfdis_relacionados IS NOT NULL
AND LOWER($1) = ANY(string_to_array(LOWER(cfdis_relacionados), '|'))
AND status NOT IN ('Cancelado','0')
`, [TARGET]);
console.log(`\n E que la referencian (${ecanc.length}):`);
for (const e of ecanc) {
console.log(` ${e.uuid} | total=$${e.total_mxn} | tipo_rel=${e.cfdi_tipo_relacion} | ${e.fecha_emision}`);
}
// ───────────────────────────────────────────────────────────────────────────
// 3) Compensación I/07 PPD ↔ E lado RECEPTOR (mismo mes)
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ ¿Entra en compensación I/07 PPD ↔ E (mes/año del CFDI)? ═══');
const fecha = new Date(c.fecha_emision);
const mesAnio = `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}`;
console.log(` CFDI mes/año: ${mesAnio}`);
console.log(` cfdi_tipo_relacion='07': ${c.cfdi_tipo_relacion === '07' ? '✓ SÍ' : '✗ NO'}`);
console.log(` metodo_pago='PPD': ${c.metodo_pago === 'PPD' ? '✓ SÍ' : '✗ NO'}`);
if (c.cfdi_tipo_relacion === '07' && c.metodo_pago === 'PPD') {
// Calcular el aporte que tendría a la compensación (suma de E del mismo mes)
const { rows: comp } = await pool.query(`
SELECT
COALESCE(SUM(
COALESCE(e.total_mxn, 0)
- COALESCE(e.iva_traslado_mxn, 0)
- COALESCE(e.ieps_traslado_mxn, 0)
- COALESCE(e.impuestos_locales_trasladado_mxn, 0)
), 0)::numeric(14,2) AS aporte
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado','0')
AND UPPER(e.rfc_receptor) = $1
AND LOWER($2) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision) = date_trunc('month', $3::timestamp)
`, [RFC, TARGET, c.fecha_emision]);
console.log(`\n Aporte a la compensación (suma E mismo mes): $${comp[0].aporte}`);
if (Number(comp[0].aporte) > 0) {
console.log(` → SÍ entra en compensación`);
} else {
console.log(` → NO entra (no hay E en mismo mes que la referencien)`);
}
}
// ───────────────────────────────────────────────────────────────────────────
// 4) ¿Aparece en el cálculo "I PUE recibidas" o "P recibidos"?
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ ¿Aparece en el cálculo directo? ═══');
console.log(` I PUE recibidas requiere: tipo_comprobante='I' AND metodo_pago='PUE'`);
console.log(` Este CFDI: tipo_comprobante='${c.tipo_comprobante}', metodo_pago='${c.metodo_pago}'`);
console.log(`${c.tipo_comprobante === 'I' && c.metodo_pago === 'PUE' ? '✓ SÍ entra' : '✗ NO entra'} (es ${c.tipo_comprobante} ${c.metodo_pago})`);
// ───────────────────────────────────────────────────────────────────────────
// 5) Predicado de filtro de activos
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ Predicado de filtro de activos sobre este CFDI ═══');
const ACTIVOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
const t1 = await pool.query(`
SELECT
(tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS}) AS regla1_directo,
(tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS}
)) AS regla2,
(tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
SELECT 1 FROM cfdis r_act
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
AND (r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS})
)) AS regla3
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
`, [TARGET]);
console.log(` regla1 (I directo activo): ${t1.rows[0].regla1_directo ? '🔴 TRUE' : '🟢 FALSE'}`);
console.log(` regla2 (P paga I activo): ${t1.rows[0].regla2 ? '🔴 TRUE' : '🟢 FALSE'}`);
console.log(` regla3 (E ref. I/P activo): ${t1.rows[0].regla3 ? '🔴 TRUE' : '🟢 FALSE'}`);
const filtrado = t1.rows[0].regla1_directo || t1.rows[0].regla2 || t1.rows[0].regla3;
console.log(` → Si "Considerar activos" OFF → ${filtrado ? '🔴 EXCLUIDO' : '🟢 PASA'}`);
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO7JE8BZ_VDOPR' } });
if (!t) { console.log('Zorro tenant no encontrado'); return; }
const pool = await tenantDb.getPool(t.id, t.databaseName);
console.log('--- E PUE EMITIDAS (cualquier fecha) ---');
const emit = await pool.query(`
SELECT EXTRACT(year FROM fecha_emision) as anio,
regimen_fiscal_emisor, count(*) as n,
SUM(total_mxn)::numeric(14,2) as total
FROM cfdis
WHERE tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND status NOT IN ('Cancelado','0')
GROUP BY 1, 2 ORDER BY 1 DESC, 2
`);
console.table(emit.rows);
console.log('\n--- E PUE RECIBIDAS (cualquier fecha) ---');
const rec = await pool.query(`
SELECT EXTRACT(year FROM fecha_emision) as anio,
regimen_fiscal_receptor, count(*) as n,
SUM(total_mxn)::numeric(14,2) as total
FROM cfdis
WHERE tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND status NOT IN ('Cancelado','0')
GROUP BY 1, 2 ORDER BY 1 DESC, 2
`);
console.table(rec.rows);
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,67 @@
/**
* Diseca 2 complementos P de Horux 360 que el usuario espera ver en mayo
* pero no aparecen. Verifica fecha_emision vs fecha_pago_p para entender
* en qué mes los está sumando el cálculo.
*/
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, nombre: true, databaseName: true },
});
const UUIDS = [
'CFACB97E-5426-48D4-A3B9-06B5D160F307',
'384CF943-EFB0-475A-B6B6-240E96088B37',
];
// Loop por todos los tenants
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
console.log(`\n>>> Tenant: ${t.rfc} (${t.nombre}) <<<`);
for (const uuid of UUIDS) {
const { rows: [c] } = await pool.query(`
SELECT uuid, type, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
cfdi_tipo_relacion,
total_mxn, monto_pago_mxn, iva_traslado_pago_mxn,
rfc_emisor, regimen_fiscal_emisor,
rfc_receptor, regimen_fiscal_receptor,
fecha_emision, fecha_pago_p, status
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
`, [uuid]);
console.log(`\n═══ CFDI ${uuid} ═══`);
if (!c) { console.log(' (NO ENCONTRADO en BD de Horux 360)'); continue; }
console.log(` Tipo: ${c.tipo_comprobante} ${c.metodo_pago || ''}`);
console.log(` Status: ${c.status}`);
console.log(` type (lado): ${c.type}`);
console.log(` rfc_emisor: ${c.rfc_emisor} (régimen ${c.regimen_fiscal_emisor})`);
console.log(` rfc_receptor: ${c.rfc_receptor} (régimen ${c.regimen_fiscal_receptor})`);
console.log(` total_mxn: $${c.total_mxn}`);
console.log(` monto_pago_mxn: $${c.monto_pago_mxn}`);
console.log(` iva_traslado_pago: $${c.iva_traslado_pago_mxn}`);
console.log(` ──────────────────────────────────────`);
console.log(` fecha_emision: ${c.fecha_emision}`);
console.log(` fecha_pago_p: ${c.fecha_pago_p}`);
console.log(` ──────────────────────────────────────`);
// Análisis: en qué mes "cae" según el cálculo de ingresos (Grupo 1 — FR_PAGO usa fecha_pago_p)
const fecPago = c.fecha_pago_p ? new Date(c.fecha_pago_p) : null;
const fecEmi = c.fecha_emision ? new Date(c.fecha_emision) : null;
if (fecPago) {
console.log(` En cálculo Ingresos: APARECE EN ${fecPago.getFullYear()}-${String(fecPago.getMonth() + 1).padStart(2, '0')}`);
console.log(` (filtro: fecha_pago_p)`);
}
if (fecEmi) {
console.log(` En filtros UI fecha: se EMITIÓ en ${fecEmi.getFullYear()}-${String(fecEmi.getMonth() + 1).padStart(2, '0')}`);
}
}
} // close tenant loop
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
/**
* Limpia el cache `metricas_mensuales` de TODOS los tenants activos.
*
* Ejecutar después de cambios fiscales en las fórmulas de ingresos/deducciones
* (dashboard.service.ts) — los valores ya escritos en el cache reflejan la
* fórmula vieja y muestran datos incorrectos para meses pasados hasta ser
* recomputados.
*
* Estrategia: TRUNCATE (vía DELETE) por tenant. La próxima consulta a un
* período pasado cae al path on-the-fly y rehidrata el cache con la fórmula
* vigente. No bloquea uso normal — solo aumenta latencia de la primera lectura.
*
* Idempotente. Por-tenant try/catch para que un tenant que falla no tumbe el
* resto.
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, nombre: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
console.log(`[Cache Refresh] Iterando ${tenants.length} tenant(s) activo(s)...\n`);
let totalRows = 0;
let okTenants = 0;
let failedTenants = 0;
for (const t of tenants) {
try {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const result = await pool.query('DELETE FROM metricas_mensuales');
const rows = result.rowCount ?? 0;
totalRows += rows;
okTenants++;
console.log(`${t.rfc.padEnd(15)} ${t.nombre.padEnd(40)}${rows.toLocaleString('es-MX')} filas borradas`);
} catch (err: any) {
failedTenants++;
console.error(`${t.rfc.padEnd(15)} ${t.nombre.padEnd(40)} → ERROR: ${err.message || err}`);
}
}
console.log(`\n[Cache Refresh] Completado:`);
console.log(` Tenants OK: ${okTenants}`);
console.log(` Tenants fallidos: ${failedTenants}`);
console.log(` Total filas: ${totalRows.toLocaleString('es-MX')}`);
console.log(`\nLa próxima consulta a un período en cache lo recomputará on-demand`);
console.log(`con las fórmulas vigentes en dashboard.service.ts.`);
await prisma.$disconnect();
process.exit(failedTenants > 0 ? 1 : 0);
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,100 @@
/**
* Configura la suscripción del tenant Horux 360 (HTS240708LJA) como Plan Custom:
* - amount: $10
* - currentPeriodEnd: hoy + 300 días
* - status: authorized
*
* Idempotente — actualiza la suscripción existente o crea una nueva si no hay.
* Resetea `lastReminderDay`/`lastReminderSentAt` para que el cron de avisos
* arranque limpio respecto al nuevo período.
*/
import { prisma } from '../src/config/database.js';
async function main() {
const RFC = 'HTS240708LJA';
const AMOUNT = 10;
const DAYS_AHEAD = 7;
const tenant = await prisma.tenant.findUnique({ where: { rfc: RFC } });
if (!tenant) {
console.error(`Tenant ${RFC} no encontrado.`);
process.exit(1);
}
const now = new Date();
const periodEnd = new Date(now.getTime() + DAYS_AHEAD * 24 * 60 * 60 * 1000);
const existing = await prisma.subscription.findFirst({
where: { tenantId: tenant.id },
orderBy: { createdAt: 'desc' },
});
console.log('Tenant:', { id: tenant.id, nombre: tenant.nombre, plan: tenant.plan });
console.log('Subscription previa:', existing ? {
id: existing.id,
plan: existing.plan,
status: existing.status,
amount: existing.amount.toString(),
currentPeriodEnd: existing.currentPeriodEnd,
} : null);
let sub;
if (existing) {
sub = await prisma.subscription.update({
where: { id: existing.id },
data: {
plan: 'custom',
amount: AMOUNT,
status: 'authorized',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
// Limpiar pending/upgrade residuales del estado anterior.
pendingPlan: null,
pendingFrequency: null,
pendingEffectiveAt: null,
upgradePreferenceId: null,
upgradeTargetPlan: null,
upgradeTargetAmount: null,
// Reset del tracker de avisos — período nuevo, ningún bucket notificado.
lastReminderDay: null,
lastReminderSentAt: null,
},
});
} else {
sub = await prisma.subscription.create({
data: {
tenantId: tenant.id,
plan: 'custom',
amount: AMOUNT,
status: 'authorized',
frequency: 'monthly',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
},
});
}
// El tenant también tiene un campo `plan` propio — alinearlo con la sub.
if (tenant.plan !== 'custom') {
await prisma.tenant.update({ where: { id: tenant.id }, data: { plan: 'custom' } });
console.log(`Tenant.plan actualizado: ${tenant.plan} → custom`);
}
console.log('Subscription final:', {
id: sub.id,
plan: sub.plan,
status: sub.status,
amount: sub.amount.toString(),
currentPeriodStart: sub.currentPeriodStart,
currentPeriodEnd: sub.currentPeriodEnd,
});
console.log(`\n✓ Plan Custom activo. Próximo cobro: ${periodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' })} ($${AMOUNT})`);
await prisma.$disconnect();
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
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(),
// Token sandbox (TEST-...) para pruebas locales sin cobro real. Conviven con
// el de prod para no estar swap-eando manualmente. Solo se usa cuando
// MP_USE_SANDBOX=true.
MP_ACCESS_TOKEN_SANDBOX: z.string().optional(),
// Toggle global: cuando true, todas las llamadas a MP usan
// MP_ACCESS_TOKEN_SANDBOX. Default false → usa MP_ACCESS_TOKEN (prod).
MP_USE_SANDBOX: z.string().transform(v => v === 'true' || v === '1').default('false'),
MP_WEBHOOK_SECRET: z.string().optional(),
MP_NOTIFICATION_URL: z.string().optional(),
// Solo dev/staging: override del payer_email enviado a MercadoPago. Útil cuando
// el owner del tenant tiene el mismo email vinculado al MP_ACCESS_TOKEN
// (vendedor) — MP rechaza con "Payer and collector cannot be the same user".
// Al setearlo, todas las llamadas a MP usan este email como payer en lugar del
// owner real. Production: dejar vacío. (string vacío se trata como undefined
// para que prod pueda dejar la línea declarada sin valor sin romper Zod.)
MP_TEST_PAYER_EMAIL: z.preprocess(
v => (v === '' ? undefined : v),
z.string().email().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(),
// Metabase (auto-registro de BDs tenant en Metabase para BI)
METABASE_URL: z.string().optional(),
METABASE_USERNAME: z.string().optional(),
METABASE_PASSWORD: z.string().optional(),
METABASE_PG_HOST: z.string().optional(),
METABASE_PG_PORT: z.string().optional(),
METABASE_PG_USER: z.string().optional(),
METABASE_PG_PASSWORD: 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());
}

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